TypeScript上級講座 #5 Discriminated unionと型ガード深掘り

#3 conditional typesとinferでunionを分配処理する道具を見ました。今回は一歩後ろに下がって、複数の形のデータをunionでモデリングする方法自体を深く扱います。

基礎講座#4でnarrowingの基本を、実践#3でreducerのactionを見ましたが、それは始まりでした。この記事ではその上にユーザー定義型ガードassertion関数、そしてbranded typesまで乗せます。

Discriminated unionをもう一度 #

要点を一行で:

すべてのメンバーが同じ名前のリテラルフィールドを持てばdiscriminated unionになります。

基本形
type Result =
  | { ok: true; data: string }
  | { ok: false; error: string };

function handle(r: Result) {
  if (r.ok) {
    console.log(r.data);    // ここでrは { ok: true; data: string }
  } else {
    console.error(r.error); // ここでrは { ok: false; error: string }
  }
}

okdiscriminatorです。この位置にbooleanでもstring literalでも、正確に分岐できる値であれば十分です。kindtypestatusのような名前がよくありますが、必ず名前があるべきというわけではありません

一つのメンバーにだけあるフィールドもdiscriminatorになる #

他のメンバーに同じ名前のフィールドが無いときも分岐します。次の例のようにです。

フィールドの有無で分岐
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; width: number; height: number };

function area(s: Shape) {
  if ('radius' in s) return Math.PI * s.radius ** 2;     // circle
  if ('side' in s) return s.side ** 2;                   // square
  return s.width * s.height;                              // rect
}

'radius' in sのようなin演算子もnarrowingに動員されます。ただしdiscriminatorを明示的に置く方(この例のkind)が普通もっと綺麗です。inパターンは外部データのように私たちが形を決められないケースに合います。

Exhaustivenessチェック — neverパターン #

unionに新しいメンバーを追加したのにswitchで処理を漏らしたら? コンパイルが捕まえてくれるようにできます。

exhaustivenessチェック
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    default: {
      const _exhaustive: never = s;     // メンバーを忘れるとここで✗
      return 0;
    }
  }
}

Shape'rect'を追加するとdefaults{ kind: 'rect'; ... }になりますが、これをnever変数に代入しようとするので赤線が出ます。新しいメンバーが入ると処理していない箇所でコンパイルが止まり、バグを事前に捕まえます。

ユーザー定義型ガード — value is X #

基礎講座でtypeof/instanceof/inでnarrowingが起こるのを見ました。これを関数にカプセル化できますが、それがユーザー定義型ガード(user-defined type guard)です。

ユーザー定義型ガード
type User = { id: string; name: string };

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === 'string' && typeof v.name === 'string';
}

function greet(value: unknown) {
  if (isUser(value)) {
    console.log(`Hello, ${value.name}`);  // ここでvalueはUser
  }
}

戻り値の型がbooleanではなくvalue is Userです。「この関数がtrueを返したら、呼び出す側ではvalueをUserに絞ってください」という約束です。

このパターンが実践 #6 fetchとAPIレスポンスの型付けで外部データを絞るときに登場しましたね。その場面の動作原理がこれでした。

危険性 — 嘘が可能です #

型ガード関数の戻り値は開発者が直接決めます。中で検査を漏らしてもコンパイラは気付けません。

偽のガード — 危険
function isUser(value: unknown): value is User {
  return true;     // コンパイラは通すが、実際は嘘
}

const x: unknown = 42;
if (isUser(x)) {
  console.log(x.name);  // コンパイルは通る。ランタイムにundefined.nameで爆発
}

型ガードを作るときは実際の検証ロジックと戻り値シグネチャが一致してこそ安全です。大規模ではzodのようなスキーマで検証とガードを一度に表現する方が安全です。

Assertion関数 — asserts value is X #

型ガードのいとこ。チェックに失敗したらthrowし、通過したらその後絞られた型で進む関数。

assertion関数
function assertIsUser(value: unknown): asserts value is User {
  if (typeof value !== 'object' || value === null) {
    throw new Error('Not a User');
  }
  const v = value as Record<string, unknown>;
  if (typeof v.id !== 'string' || typeof v.name !== 'string') {
    throw new Error('Not a User');
  }
}

function process(value: unknown) {
  assertIsUser(value);
  console.log(value.name);   // ここからvalueはUser
}

if分岐で囲まなくて済みます。失敗したらthrow、通過したら自動絞り込み。「この時点からは絶対にUserでなければ次の行を実行しない」のような直線的なロジックに合います。

asserts condition形式もあります。

asserts condition
function assert(condition: unknown, message?: string): asserts condition {
  if (!condition) throw new Error(message);
}

function getName(user: User | null) {
  assert(user !== null, 'userはnullになり得ない');
  return user.name;     // ここでuserはUserに絞られる
}

assertが通過したら、その後コンパイラがuser !== nullを事実として受け入れます。深いifなしでコードを直線化するときに綺麗です。

落とし穴 — 関数式には使えない #

assertion関数には一つ制約があります — 明示的にシグネチャが書かれた関数でだけ動作します。アロー関数に推論で置くと絞り込みが起こりません。

これは動作しない
const assert = (condition: unknown, message?: string) => {
  if (!condition) throw new Error(message);
};

function getName(user: User | null) {
  assert(user !== null);
  return user.name;     // ✗ userが絞られない (User | null)
}

assertion関数は必ずasserts ...シグネチャを明示した宣言された関数でなければなりません。よく出会う落とし穴なので、覚えておくと良いです。

Branded types — 同じstringも違う型に #

UserIdPostIdが両方stringなら、TypeScriptは二つを入れ替えても捕まえられません。同じ形だからです。

これは捕まらない
type UserId = string;
type PostId = string;

function getUser(id: UserId): User { /* ... */ }

const postId: PostId = 'p_42';
getUser(postId);     // コンパイル通過 — 実は意図と違う

このミスを捕まえたいならbranded types(またはnominal typing)を使います。要点は実際にはstringだが、型レベルでだけ違う形にすることです。

brandパターン
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };

function getUser(id: UserId): User { /* ... */ }

const a = 'u_1' as UserId;
getUser(a);                              // OK

const b: PostId = 'p_42' as PostId;
getUser(b);                              // ✗ PostIdはUserIdではない

getUser('raw-string');                   // ✗ 単なるstringも通らない

string & { __brand: 'UserId' }が要点です。ランタイムでは単なるstringですが、型レベルでは__brandで違うアイデンティティを持つようになります。違うbrandや単なるstringは互換性がありません。

もう一段上 — 検証されたデータの表現 #

ブランドは単純にIDを区別するところで終わりません。検証を通過した値を型として表現することもできます。

検証を通過したメール
type Email = string & { readonly __brand: 'Email' };

function isValidEmail(s: string): s is Email {
  return /^[^@]+@[^@]+\.[^@]+$/.test(s);
}

function sendEmail(to: Email, subject: string) { /* ... */ }

const raw = '何でも';
sendEmail(raw, 'こんにちは');               // ✗ stringはEmailではない

if (isValidEmail(raw)) {
  sendEmail(raw, 'こんにちは');             // OK — ガードを通過した後でだけ
}

sendEmailは「検証されたメール」を受け取るとシグネチャに固定しておきます。呼び出す側はガードを一度通過してこそ送れます。検証漏れがコンパイル段階で捕まる。実務的価値が大きいです。

もう一段 — Result型でエラーモデリング #

throwの代わりにエラーを値として扱うパターン。RustのResultに似ています。

Resultパターン
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    const user = (await res.json()) as User;   // 実戦では検証必要
    return { ok: true, value: user };
  } catch (e) {
    return { ok: false, error: e as Error };
  }
}

const r = await fetchUser('u_1');
if (r.ok) {
  console.log(r.value.name);
} else {
  console.error(r.error);
}

discriminated unionの強みがそのまま活きます。呼び出す側が必ず両方の分岐を処理しなければコンパイルが止まります。throw/try-catchが意識せずに散らばるコードよりも安全です。

このパターンには実践的な意味があります。小さな関数ではthrowが軽くて十分ですが、共有されるAPI境界ではResultモデルがしばしばより安全です。

narrowingが壊れるケース #

最後に、narrowingが動作しないよくある落とし穴二つ。

1) 非同期の間に絞り込みを失う #

awaitの間に絞り込みが解ける
async function f(value: string | null) {
  if (value === null) return;

  await something();
  console.log(value.length);   // ここでvalueは依然string
}

この例は動作します。しかし他のコードが間に挟まりvalueを変更する可能性がある場合(クロージャでキャプチャされた場合など)、コンパイラが絞り込みを捨てることがあります。実務では絞り込んだ値を別の変数に保存して使う方が安全です。

絞り込んだ結果を捉えておく
async function f(value: string | null) {
  if (value === null) return;
  const safe = value;          // 絞り込まれたstringを捉えておく

  await something();
  console.log(safe.length);    // 安全
}

2) メソッド呼び出し後に絞り込みを失う #

メソッド呼び出し後
type Box = { item?: { name: string } };

function f(box: Box) {
  if (box.item === undefined) return;

  doSomething();

  console.log(box.item.name);   // コンパイラがbox.itemを再び疑うことがある
}

doSomething()がboxを変更した可能性をコンパイラが意識し始めると、再びundefinedの可能性が生き返ります。このときも絞り込んだ値を変数に捉えておくパターンが最も安全です。

まとめ #

今回整理した内容:

  • discriminated union — 同じ名前のリテラルフィールド、またはinで分岐可能
  • exhaustivenessチェック — never変数で新しいメンバー漏れを捕まえる
  • ユーザー定義型ガード — value is Xシグネチャ。嘘の危険に注意
  • assertion関数 — asserts value is X。明示宣言された関数でだけ動作
  • branded types — string & { __brand: 'UserId' }で同じ形を区別
  • 検証を通過した値を型として表現 (Emailパターン)
  • Resultパターン — エラーを値としてモデリング
  • narrowingが壊れるケース — 絞り込んだ値を変数に捉えておく

次の記事(#6 モジュールと.d.ts)では一つのモジュールに閉じていた視野を広げ、外部ライブラリの型をどう扱い拡張するか — 宣言ファイルとmodule augmentationを扱います。

X