TypeScript

TypeScriptのユニオン型と型ガード:型の絞り込みからneverによる網羅チェックまで

この記事でわかること

TypeScriptのユニオン型と型ガードを配送状態の実装で解説。typeof、unknownの検証、判別可能なユニオン、neverによる処理漏れの確認を学びます。

ユニオン型は、値が取り得る型を | で列挙します。string | number のような値は、条件分岐で型を絞り込んでから、その型に固有の操作を行います。any に変えてしまうと、存在しないメソッドを呼び出す誤りを見つけにくくなります。

関数と基本的な型が分かる方向けに、入力値の整形と配送状態の表示を実装します。掲載コードはTypeScript 4.9.4の strict で検証しています。型による確認と外部入力の実行時検証を区別して進めます。

ユニオン型と型ガードを実装する

typeof で型を絞り込む

ファイル: src/reference.ts

export function formatReference(value: string | number): string {
  if (typeof value === "number") {
    return `No.${value.toFixed(0)}`;
  }
  return value.trim().toUpperCase();
}

console.log(formatReference(42)); // No.42
console.log(formatReference(" ab-7 ")); // AB-7

数値の分岐で return した後は、残る候補が文字列だけになります。このように、判定や制御の流れから型の候補を減らすことを「型の絞り込み」と呼びます。typeof などを使って型を判定する仕組みが型ガードです。Narrowingの公式解説

この関数は数値を小数点以下0桁に丸めて表示します。IDとして整数だけを許すような要件なら、別途 Number.isInteger() などで入力を検証してください。型が number であることは、業務上の有効な番号であることとは異なります。

状態と必要なデータを組み合わせる

単なる状態文字列と、任意の宛先・エラー文を組み合わせると、「配送成功なのに宛先がない」データも作れてしまいます。状態ごとにオブジェクトの形を分け、共通の status に異なる文字列リテラルを指定します。これが判別可能なユニオン型です。

ファイル: src/delivery.ts

export type DeliveryState =
  | { status: "pending" }
  | { status: "sent"; recipient: string }
  | { status: "failed"; reason: string };

function assertNever(value: never): never {
  throw new Error(`想定外の状態: ${JSON.stringify(value)}`);
}

export function describeDelivery(state: DeliveryState): string {
  switch (state.status) {
    case "pending":
      return "配送待ち";
    case "sent":
      return `配送完了: ${state.recipient}`;
    case "failed":
      return `配送失敗: ${state.reason}`;
    default:
      return assertNever(state);
  }
}

export function isDeliveryState(value: unknown): value is DeliveryState {
  if (typeof value !== "object" || value === null || !("status" in value)) {
    return false;
  }

  switch (value.status) {
    case "pending":
      return true;
    case "sent":
      return "recipient" in value && typeof value.recipient === "string";
    case "failed":
      return "reason" in value && typeof value.reason === "string";
    default:
      return false;
  }
}

sent の分岐では recipientfailed の分岐では reason を使用できます。別の状態のプロパティを無条件に読むと型エラーになり、状態とデータの取り違えを見つけやすくなります。

isDeliveryState() は、外部から受け取る値に必要な項目があるかを確認する型ガードです。必要な型の形を確かめますが、宛先が実在することや、エラー文が空でないことまでは確認していません。また、余分な項目は拒否・削除しません。必要な業務ルールや出力項目の選別は別途実装します。

unknown を検証してから使う

ファイル: src/main.ts

import { describeDelivery, isDeliveryState } from "./delivery.js";

const inputs: unknown[] = [
  { status: "pending" },
  { status: "sent", recipient: "reader@example.com" },
  { status: "failed", reason: "宛先不明" },
  { status: "sent" },
  null,
];

for (const input of inputs) {
  if (!isDeliveryState(input)) {
    console.log("不正な配送状態");
    continue;
  }
  console.log(describeDelivery(input));
}

順に「配送待ち」「配送完了: reader@example.com」「配送失敗: 宛先不明」と表示され、宛先が欠けたデータと null は拒否されます。as DeliveryState と断定するだけでは、この検証は実行されません。

型述語の value is DeliveryState は、コンパイラが関数の検証内容を完全に証明してくれる仕組みではありません。実装の間違いで誤った絞り込みができてしまうため、欠けた項目・間違った型・未定義の状態などをテストします。

never で状態の処理漏れを見つける

すべての状態を処理した後、default に残る候補はありません。その値の型が never です。DeliveryState{ status: "retrying"; attempt: number } を追加して switch を変更しなければ、assertNever(state) に渡す値が never ではなくなり、型エラーになります。

状態を追加したときは、表示処理だけでなく外部入力の検証も更新します。isDeliveryState()default は未知のデータを拒否するためのものなので、型を増やすだけでは新しい状態を受け入れません。型・検証・処理を一緒に見直してください。

よくある判定の落とし穴

  • if (value) は空文字や 0 も除外します。値がない場合だけを除きたいなら、nullundefined を明示的に判定します。
  • typeof null"object" です。オブジェクトのプロパティに触れる前に null を除外します。
  • in 演算子はプロトタイプから継承したプロパティにも反応します。型の形を確認するこの例と、自分自身のプロパティだけを許可する検証は目的が異なります。

実行方法と練習問題

空の作業用フォルダで npm init -ynpm install --save-dev typescript @types/node を実行します。Node.jsと型定義のバージョンを対応させ、上の3ファイルを src に保存します。

ファイル: tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmitOnError": true,
    "rootDir": "src",
    "outDir": "dist",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
npx tsc -p tsconfig.json
node dist/reference.js
node dist/main.js

練習として再試行中の retrying 状態を追加します。先に処理漏れの型エラーを確認し、その後に再試行回数を表示する分岐と入力検証を追加してください。型は実行時には残らないため、変更後は再ビルドが必要です。

型ガードが必要になる場面

欠けた値をどう扱うか決める

任意項目の読み取りでは、値がない場合に既定値を使うのか、エラーを返すのかを決めます。非nullアサーションで診断だけを消すと、その判断がコードに残りません。

型述語にもテストを用意する

value is Userのような型述語は、実装した判定が正しいという前提で絞り込みに使われます。正しい値だけでなく、null、配列、項目不足、型違いの入力も確かめてください。

関連記事

TypeScriptとは・学習順・目的別の記事一覧へ戻る

y.
WRITTEN BY

y_ymo10

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

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