Chapter 09

実践パターン

as const・ユーティリティ型・コンポーネント設計パターンを学びます

35 min

このチャプターで学ぶこと

  • as const でリテラル型を活用できる
  • never を使った網羅性チェックを理解できる
  • 型アサーション(as)の正しい使い方を知る
  • コンポーネントのバリアントパターンを実装できる
  • @types パッケージの役割を理解できる

実践パターン

ここまでで TypeScript の基本文法と React での使い方を学びました。このチャプターでは、実際のプロジェクトで頻繁に使われる実践的なパターンを紹介します。as constnever による網羅性チェック、型アサーション、そしてコンポーネント設計パターンなど、現場で「知っていると差がつく」テクニックを身につけましょう。

as const でリテラル型を推論させる

問題:オブジェクトの値が広い型に推論される

まず、普通のオブジェクト定義で何が起きるか見てみましょう。

// 普通のオブジェクト定義
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};

// config.apiUrl の型は string(広い型)
// config.timeout の型は number(広い型)

TypeScript は config.apiUrl"https://api.example.com" というリテラル型ではなく、string 型として推論します。多くの場合はこれで問題ありませんが、特定の文字列だけを許可したい場面ではこの挙動が困ります。

as const で解決する

as const を付けると、オブジェクトや配列のすべての値がリテラル型として推論され、さらに readonly(読み取り専用)になります。

// as const を付ける
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
} as const;

// config.apiUrl の型は "https://api.example.com"(リテラル型)
// config.timeout の型は 5000(リテラル型)
// config 全体が readonly になる
// config.timeout = 3000; // エラー!読み取り専用
📝 Laravel の定数と比較すると

Laravel で config/app.php に定数を定義するのと似た感覚です。as const を使うことで「この値は変更されない定数だ」と TypeScript に伝えられます。PHP の constdefine() に近い概念です。

配列での as const

配列に as const を使うと、readonly なタプル型になります。

// as const なし
const roles = ["admin", "editor", "viewer"];
// 型: string[]

// as const あり
const roles = ["admin", "editor", "viewer"] as const;
// 型: readonly ["admin", "editor", "viewer"]

// roles の要素からユニオン型を作る
type Role = (typeof roles)[number];
// 型: "admin" | "editor" | "viewer"

この typeof roles[number] というパターンは非常によく使います。配列から自動的にユニオン型を生成できるため、値の定義と型の定義を一箇所にまとめられるのが大きな利点です。

// 実践例:ステータス定義
const ORDER_STATUSES = ["pending", "processing", "shipped", "delivered"] as const;
type OrderStatus = (typeof ORDER_STATUSES)[number];
// 型: "pending" | "processing" | "shipped" | "delivered"

// セレクトボックスの選択肢として使える
function StatusSelect({ value, onChange }: {
  value: OrderStatus;
  onChange: (status: OrderStatus) => void;
}) {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value as OrderStatus)}>
      {ORDER_STATUSES.map((status) => (
        <option key={status} value={status}>
          {status}
        </option>
      ))}
    </select>
  );
}
💡 ヒント

as const定数の定義に使いましょう。「この値は絶対に変更しない」と自信を持てるものだけに付けます。動的に変わるデータには使いません。

never を使った網羅性チェック

switch 文で全パターンを処理しているか確認する

ユニオン型を switch で処理するとき、全パターンを確実にカバーしているかをコンパイル時にチェックできます。

type Status = "pending" | "approved" | "rejected";

function getStatusLabel(status: Status): string {
  switch (status) {
    case "pending":
      return "保留中";
    case "approved":
      return "承認済み";
    case "rejected":
      return "却下";
    default: {
      // ここに到達するのは全パターンを処理していない場合だけ
      const _exhaustive: never = status;
      return _exhaustive;
    }
  }
}

never 型は「絶対に到達しない」ことを表す型です。すべての case を処理していれば、default に来る値は存在しないため never 型に代入できます。

新しい値が追加されたときにエラーで教えてくれる

この網羅性チェックの真価は、ユニオン型に新しい値を追加したときに発揮されます。

// Status に新しい値を追加する
type Status = "pending" | "approved" | "rejected" | "cancelled";

function getStatusLabel(status: Status): string {
  switch (status) {
    case "pending":
      return "保留中";
    case "approved":
      return "承認済み";
    case "rejected":
      return "却下";
    // "cancelled" の case を書き忘れている!
    default: {
      // コンパイルエラー!
      // Type '"cancelled"' is not assignable to type 'never'
      const _exhaustive: never = status;
      return _exhaustive;
    }
  }
}

"cancelled"case を書き忘れると、default"cancelled" が流れ込むため、never 型への代入でエラーになります。これにより対応漏れをコンパイル時に検知できます。

📝 Rails の enum と比較すると

Rails の enum でステータスを追加したとき、関連するビューやロジックの修正漏れがないか手動で確認する必要がありますよね。TypeScript の網羅性チェックは、それを自動で検出してくれる仕組みです。

ヘルパー関数として切り出す

毎回 const _exhaustive: never = status と書くのは冗長なので、ヘルパー関数にすることが多いです。

// 網羅性チェック用のヘルパー関数
function assertNever(value: never): never {
  throw new Error(`予期しない値: ${value}`);
}

function getStatusLabel(status: Status): string {
  switch (status) {
    case "pending":
      return "保留中";
    case "approved":
      return "承認済み";
    case "rejected":
      return "却下";
    case "cancelled":
      return "キャンセル";
    default:
      return assertNever(status);
  }
}

型アサーション(as)の正しい使い方

型アサーションとは

型アサーション(as)は、TypeScript に「この値はこの型だと私が保証する」と伝える機能です。

// DOM 操作での例
const input = document.getElementById("email");
// input の型は HTMLElement | null

// 型アサーションで HTMLInputElement だと伝える
const emailInput = document.getElementById("email") as HTMLInputElement;
// emailInput の型は HTMLInputElement
emailInput.value = "test@example.com"; // .value にアクセスできる

型アサーションが許容されるケース

型アサーションは TypeScript の型チェックを一部バイパスするため、安易に使うべきではありません。ただし、以下のケースでは実務上よく使われます。

// 1. DOM 要素の型を指定する
const canvas = document.querySelector("canvas") as HTMLCanvasElement;

// 2. 外部 API のレスポンスに型を付ける
const data = (await response.json()) as ApiResponse;

// 3. イベントハンドラでのターゲット要素
function handleSubmit(e: React.FormEvent) {
  const form = e.target as HTMLFormElement;
  const formData = new FormData(form);
}
⚠️ 型アサーションの危険性

型アサーションは TypeScript の安全性を弱める行為です。以下のようなコードは実行時エラーの原因になります。

// 危険!実際の型と一致しない可能性がある
const user = someData as User;
user.name.toUpperCase(); // someData に name がなければ実行時エラー

// より安全な方法:型ガードを使う
function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    "name" in data &&
    typeof (data as User).name === "string"
  );
}

as を使うときは「本当にその型であることを確信しているか?」と自問しましょう。

as unknown as T ── 二重アサーション

まれに、直接アサーションできない場合があります。

// 直接アサーションできない(型の互換性がない)
const str = "hello" as number; // エラー!

// unknown を経由すると通る(二重アサーション)
const str = "hello" as unknown as number; // エラーにならない

二重アサーションは TypeScript の型チェックを完全にバイパスします。テストコードなど特殊な場面を除いて、使わないようにしましょう。

コンポーネントのバリアントパターン

Discriminated Union(判別付きユニオン)を使ったバリアント

React コンポーネントで「パターンによって異なる Props を受け取る」場合、判別付きユニオンが最も安全なアプローチです。

// ボタンのバリアント型を定義
type ButtonProps = {
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
} & (
  | {
      variant: "primary";
      // primary には特別な Props はなし
    }
  | {
      variant: "outline";
      borderColor?: string; // outline のみ枠線の色を指定できる
    }
  | {
      variant: "icon";
      icon: React.ReactNode; // icon バリアントではアイコンが必須
      "aria-label": string;  // アクセシビリティのためにラベルも必須
    }
);

function Button(props: ButtonProps) {
  const { size = "md", disabled } = props;

  // variant ごとに処理を分岐
  switch (props.variant) {
    case "icon":
      return (
        <button
          disabled={disabled}
          aria-label={props["aria-label"]}
          className={`btn btn-icon btn-${size}`}
        >
          {props.icon}
        </button>
      );
    case "outline":
      return (
        <button
          disabled={disabled}
          className={`btn btn-outline btn-${size}`}
          style={{ borderColor: props.borderColor }}
        >
          {props.children}
        </button>
      );
    case "primary":
    default:
      return (
        <button
          disabled={disabled}
          className={`btn btn-primary btn-${size}`}
        >
          {props.children}
        </button>
      );
  }
}

使う側では TypeScript がバリアントに応じた Props だけを許可してくれます。

// OK: primary は children だけ
<Button variant="primary">送信</Button>

// OK: icon にはアイコンとラベルが必要
<Button variant="icon" icon={<TrashIcon />} aria-label="削除" />

// エラー!icon バリアントなのに aria-label がない
<Button variant="icon" icon={<TrashIcon />} />

// エラー!primary バリアントに borderColor は指定できない
<Button variant="primary" borderColor="red">送信</Button>
💡 ヒント

判別付きユニオンの「判別プロパティ」(ここでは variant)には、リテラル型のユニオンを使います。これにより TypeScript は variant の値を見て、どの型パターンかを自動的に判別できます。

children を持つバリアントと持たないバリアント

より実践的なパターンとして、「テキストボタン」と「アイコンボタン」で children の有無を制御する例を見てみましょう。

type TextButtonProps = {
  variant: "text";
  children: React.ReactNode;
};

type IconButtonProps = {
  variant: "icon";
  icon: React.ReactNode;
  "aria-label": string;
  children?: never; // children を渡すとエラーにする
};

type ButtonProps = (TextButtonProps | IconButtonProps) & {
  onClick?: () => void;
  disabled?: boolean;
};

children?: never を使うことで、アイコンボタンに children を渡そうとするとコンパイルエラーになります。

Polymorphic コンポーネント(as prop パターン)

ライブラリでよく見かける「as prop でレンダリングする HTML 要素を変更できる」パターンも紹介します。

// 簡易版の Polymorphic コンポーネント
type BoxProps<T extends React.ElementType = "div"> = {
  as?: T;
  children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;

function Box<T extends React.ElementType = "div">({
  as,
  children,
  ...rest
}: BoxProps<T>) {
  const Component = as || "div";
  return <Component {...rest}>{children}</Component>;
}

// 使い方
<Box>これは div</Box>
<Box as="section">これは section</Box>
<Box as="a" href="/about">これは a タグ</Box>
📝 メモ

Polymorphic コンポーネントの型定義は複雑です。実務では Chakra UI や Radix Primitives などのライブラリが提供する型を利用することが多いため、「こういうパターンがある」と知っておけば十分です。

サードパーティライブラリの型: @types パッケージ

DefinitelyTyped とは

JavaScript で書かれたライブラリには、型定義が含まれていないものがあります。そのようなライブラリの型定義を集めた巨大なリポジトリが DefinitelyTyped です。

# React 自体は JavaScript で書かれているが、型定義は別パッケージ
npm install react
npm install --save-dev @types/react @types/react-dom

# lodash の型定義
npm install lodash
npm install --save-dev @types/lodash

# Express の型定義
npm install express
npm install --save-dev @types/express

@types/ パッケージは devDependencies に追加します。本番のビルドには含まれず、開発時の型チェックにのみ使われます。

📝 composer require --dev と同じ

Laravel で composer require --dev phpstan/phpstan のように開発用パッケージを追加するのと同じ感覚です。@types パッケージもあくまで開発支援ツールであり、実行時のコードには影響しません。

型定義がない場合の対処法

まれに @types パッケージが存在しないライブラリもあります。その場合の対処法をいくつか紹介します。

// 方法 1: declare module で最低限の型を定義する
// src/types/some-library.d.ts
declare module "some-library" {
  export function doSomething(input: string): number;
  export default function init(config: { apiKey: string }): void;
}

// 方法 2: any で逃げる(最終手段)
// src/types/untyped-lib.d.ts
declare module "untyped-lib" {
  const value: any;
  export default value;
}
⚠️ 注意

declare moduleany を使うのは最終手段です。型安全性がなくなるため、使う箇所が限定されている場合は必要な関数だけを正確に型定義しましょう。

最近のトレンド: 型定義の同梱

最近のライブラリ(Zod、tRPC、Prisma など)は、ライブラリ自体が TypeScript で書かれているか、型定義を同梱しています。この場合は @types パッケージは不要です。

# 型定義が同梱されている例(@types 不要)
npm install zod        # TypeScript 製
npm install prisma     # 型を自動生成
npm install next       # 型定義を同梱

パッケージが型定義を同梱しているかは、npm のページで TS アイコンが付いているかで確認できます。

as の使いすぎに注意

最後に、このチャプターの重要な教訓をまとめます。

// 悪い例:as を乱用して TypeScript を黙らせる
const data = fetchData() as any as UserData;
const element = getElement() as any;
const result = calculate() as number;

// 良い例:型ガードや適切な型定義を使う
function isUserData(data: unknown): data is UserData {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "name" in data
  );
}

const data = fetchData();
if (isUserData(data)) {
  // ここでは data は UserData 型
  console.log(data.name);
}

as を使うたびに「ここは TypeScript の型チェックが効いていない」と意識しましょう。プロジェクト内で as の使用箇所を定期的にレビューし、型ガードや適切な型定義に置き換えられないか検討するのが良い習慣です。

💡 as の使用を制限する ESLint ルール

@typescript-eslint/consistent-type-assertions ルールを使うと、as の使い方を制限できます。チームで「as は DOM 操作と API レスポンスにのみ許可する」といったルールを決めておくと、コードの安全性が高まります。

✍ やってみよう:バリアント付き Button コンポーネントを作ろう

判別付きユニオンを使って、型安全な Button コンポーネントを実装してみましょう。

要件:

  1. 3 つのバリアント: "solid", "outline", "ghost"
  2. 共通 Props: size"sm" | "md" | "lg"、デフォルト "md")、disabledonClick
  3. solid バリアントは colorScheme"blue" | "red" | "green")を受け取る
  4. outline バリアントは borderWidthnumber、デフォルト 1)を受け取る
  5. ghost バリアントは追加 Props なし
  6. すべてのバリアントに children: React.ReactNode が必須

ステップ 1: 型を定義する

// まず各バリアントの Props 型を定義
type CommonProps = {
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
};

type SolidProps = CommonProps & {
  variant: "solid";
  colorScheme: "blue" | "red" | "green";
};

type OutlineProps = CommonProps & {
  variant: "outline";
  borderWidth?: number;
};

type GhostProps = CommonProps & {
  variant: "ghost";
};

type ButtonProps = SolidProps | OutlineProps | GhostProps;

ステップ 2: コンポーネントを実装する

function Button(props: ButtonProps) {
  const { size = "md", disabled, onClick, children } = props;

  // variant ごとのスタイルを決める
  const getStyle = (): React.CSSProperties => {
    switch (props.variant) {
      case "solid": {
        const colors = {
          blue: "#2563eb",
          red: "#dc2626",
          green: "#16a34a",
        };
        return {
          backgroundColor: colors[props.colorScheme],
          color: "white",
          border: "none",
        };
      }
      case "outline":
        return {
          backgroundColor: "transparent",
          color: "#333",
          border: `${props.borderWidth ?? 1}px solid #333`,
        };
      case "ghost":
        return {
          backgroundColor: "transparent",
          color: "#333",
          border: "none",
        };
      default:
        // ここで網羅性チェックも追加してみよう!
        return assertNever(props);
    }
  };

  // size に応じたパディング
  const sizeStyles: Record<string, React.CSSProperties> = {
    sm: { padding: "4px 8px", fontSize: "12px" },
    md: { padding: "8px 16px", fontSize: "14px" },
    lg: { padding: "12px 24px", fontSize: "16px" },
  };

  return (
    <button
      disabled={disabled}
      onClick={onClick}
      style={{
        ...getStyle(),
        ...sizeStyles[size],
        borderRadius: "6px",
        cursor: disabled ? "not-allowed" : "pointer",
        opacity: disabled ? 0.5 : 1,
      }}
    >
      {children}
    </button>
  );
}

ステップ 3: 使ってみる

function App() {
  return (
    <div style={{ display: "flex", gap: "8px", padding: "20px" }}>
      <Button variant="solid" colorScheme="blue">
        送信
      </Button>
      <Button variant="outline" borderWidth={2}>
        キャンセル
      </Button>
      <Button variant="ghost" size="sm">
        詳細を見る
      </Button>

      {/* 以下はエラーになることを確認しよう */}
      {/* <Button variant="solid">色指定なし</Button> */}
      {/* <Button variant="ghost" colorScheme="blue">余計な Props</Button> */}
    </div>
  );
}

チャレンジ:

  • as consttypeof を使って、colorScheme の選択肢を定数配列から生成してみよう
  • assertNever ヘルパー関数を実装して、default ケースに追加してみよう
  • 新しいバリアント "link" を追加して、href を必須 Props にしてみよう(追加したら switch が即エラーになることを確認!)

まとめ

次のチャプターでは、既存の JavaScript React プロジェクトを TypeScript に段階的に移行する方法を学びます。これまで学んだ知識の総まとめとして、実践的な移行手順を紹介します。