TypeScript

TypeScriptの実践的な型設計:ユニオン・satisfies・unknownの使い分け

この記事でわかること

TypeScriptの実践的な型設計をコードで解説。判別可能なユニオンによる状態管理、neverの分岐漏れ検出、satisfiesによる設定の型検査、unknownを使ったAPI応答の検証を学べます。TypeScript 4.9以降に対応。

TypeScriptで実務のバグを減らすには、状態ごとの違いを判別可能なユニオンで表し、設定値をsatisfiesで検査し、外部データをunknownから検証するのが有効です。この記事では、記事を取得して表示する処理を例に、それぞれの役割と使い分けを解説します。

対象はTypeScriptの基本的な型と関数を学んだ方です。コード例はTypeScript 4.9以降の構文を使い、4.9.4・strictモードで検証しています。satisfiesを含むため、4.8以前ではそのまま使用できません。

1. オプショナルプロパティだけでは不正な状態が残る

記事の読み込み状態を、次のように定義したとします。

type Post = { id: string; title: string };

type LooseState = {
  loading: boolean;
  data?: Post;
  error?: string;
};

// 成功なのか失敗なのかが曖昧でも、型チェックを通る
const ambiguous: LooseState = {
  loading: false,
  data: { id: "post-1", title: "型設計の基本" },
  error: "読み込みに失敗しました",
};

この型では、データとエラーを同時に持つ状態も作れます。画面側で条件を追加する前に、「この画面で許可する状態」を型に書き出しましょう。以下では、再取得中に古い記事を表示する仕様は扱わず、各状態を排他的に管理します。

2. 判別可能なユニオンで状態とデータを結びつける

共通のstatusプロパティに異なる文字列リテラルを指定すると、分岐に応じて利用できるプロパティが絞り込まれます。

type LoadState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Post }
  | { status: "error"; message: string };

function getLabel(state: LoadState): string {
  switch (state.status) {
    case "idle":
      return "記事を選んでください";
    case "loading":
      return "読み込み中";
    case "success":
      return state.data.title;
    case "error":
      return state.message;
    default:
      return assertNever(state);
  }
}

function assertNever(value: never): never {
  throw new Error("未対応の状態です");
}

successの分岐ではdataが存在すると判断されるため、data!のような非nullアサーションは不要です。statusだけがsuccessでdataがない値は、この型に代入できません。

neverを使うのは分岐漏れを見つけるためです。たとえばLoadStateにcancelledという状態を追加すると、未処理の値をassertNeverへ渡せなくなり、対応すべき箇所が型エラーになります。外部入力まで自動的に安全になるわけではなく、入力の検証は別に必要です。

参考:TypeScript Handbook — Discriminated unions / Exhaustiveness checking

3. satisfiesで設定の形を確かめる

設定オブジェクトには、「許可するキーを揃えたいが、各値の使いやすい型は残したい」という場面があります。

type PageName = "home" | "articles";
type Route = string | { path: string; requiresAuth: boolean };

const routes = {
  home: "/",
  articles: { path: "/articles", requiresAuth: false },
} satisfies Record<PageName, Route>;

routes.home.toUpperCase();
routes.articles.path.toUpperCase();

satisfiesは、値が指定した型を満たすかをコンパイル時に確認します。この例ではhomeを文字列として、articlesをオブジェクトとして扱えます。オブジェクトリテラルのキーをarticelsと誤記した場合なども検出できます。

一方、const routes: Record<PageName, Route>と型注釈を付けると、各プロパティはRouteとして扱われ、文字列用の操作の前に絞り込みが必要になります。どちらが正しいかは、その後の使い方次第です。

satisfiesは「すべての値を必ず文字列リテラル型のまま保持する機能」ではありません。また、実行時の検証やオブジェクトの凍結も行いません。リテラル型とreadonlyが必要ならas constの役割も検討し、用途を分けてください。

参考:TypeScript 4.9 — The satisfies Operator

4. APIの応答はunknownから検証する

APIの型定義を書いても、実際の応答が定義どおりになるとは限りません。as Postは、通信結果を検証する処理ではありません。

function parsePost(value: unknown): Post {
  if (
    typeof value !== "object" ||
    value === null ||
    Array.isArray(value)
  ) {
    throw new Error("記事データはオブジェクトである必要があります");
  }

  if (
    !("id" in value) || typeof value.id !== "string" ||
    !("title" in value) || typeof value.title !== "string"
  ) {
    throw new Error("記事のidとtitleは文字列である必要があります");
  }

  return { id: value.id, title: value.title };
}

async function fetchPost(url: string): Promise<Post> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`記事の取得に失敗しました: ${response.status}`);
  }

  const value: unknown = await response.json();
  return parsePost(value);
}

unknownとして受け取ると、検証せずにプロパティを使うことを防げます。typeof、nullチェック、in演算子で値を絞り込み、確認できたプロパティだけを持つ新しいオブジェクトを返しています。in演算子によるこの書き方は、TypeScript 4.9のプロパティ絞り込みに対応しています。

この関数が確認するのはidとtitleが文字列であることです。空文字の禁止、長さ制限、URL形式など、業務上の条件が必要なら検証を追加します。ネストや項目が増えた場合は、実行時の検証と型定義を一元管理できるスキーマ検証ライブラリの導入も選択肢です。

参考:TypeScript 4.9 — Unlisted Property NarrowingTypeScript Handbook — Type Assertions

5. 実務での使い分け

場面使用する型・構文確認すること
読み込み中・成功・失敗の管理判別可能なユニオン状態ごとに必要な値が揃うか
switchの分岐漏れ防止never追加した状態に対応したか
コード内の設定オブジェクトsatisfiesキーと値が期待する型を満たすか
API・JSONなどの外部入力unknownと実行時の検証届いた値が実際に利用できる形か

まずはstrictモードを有効にして、外部から値を受け取る箇所と、画面の状態を持つ箇所から見直すと導入しやすくなります。既存プロジェクトではstrictを有効にするとエラーが増える場合があるため、変更範囲を確認しながら進めてください。

よくある疑問

satisfiesを付ければAPIの応答も安全になりますか?

なりません。satisfiesはコンパイル時の型検査です。APIの応答はunknownとして受け取り、実行時に内容を検証する必要があります。

asは使わないほうがよいですか?

常に禁止する必要はありません。ただし型アサーションは実行時の検証を追加しません。特に外部データをasで目的の型として扱う前に、保証の根拠があるかを確認しましょう。

ユニオンはReact専用の書き方ですか?

いいえ。TypeScriptの型の仕組みなので、React以外でも使えます。フォーム送信、バックグラウンド処理、ファイル解析など、状態に応じて必要なデータが変わる処理にも適用できます。

関連する開発ノート

y.
WRITTEN BY

y_ymo10

SEの部屋で、TypeScript・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →