ウェブエンジニア問題集

型の絞り込み(Narrowing) — typeof・in・判別可能なユニオン

前章でユニオン型を学びました。string | number のようなユニオン型の変数は、そのままではどちらの型か確定していないため、片方にしかない操作が使えません。

型の絞り込み(Narrowing)は、条件分岐によってユニオン型をより具体的な型に狭めていく仕組みです。TypeScriptのコンパイラが制御フロー(if文やswitch文)を解析して、分岐の中では型が確定していると判断してくれます。

学習者学習者

string | number のままだと .toUpperCase() でエラーになる…。どうやって「今は string」って分からせればいいの?


typeof による絞り込み

最もシンプルな方法です。JavaScriptの typeof 演算子を使います。

function formatValue(value: string | number): string {
  if (typeof value === 'string') {
    // この中では value は string 型
    return value.toUpperCase();
  }
  // ここに到達する時点で value は number 型
  return value.toFixed(2);
}

TypeScriptは if (typeof value === 'string') の中では valuestring であることを理解し、else やその後のコードでは残りの number であることを推論します。

typeof が返す値と対応する型は、'string''number''boolean''object''function''undefined''bigint''symbol' です。

typeofの限界

typeof null'object' を返すため、null の判定には使えません。また、オブジェクト同士の区別(UserAdmin など)にも使えません。

typeof null;        // 'object' — JavaScriptの歴史的なバグ
typeof [];          // 'object' — 配列もobject
typeof { name: '' }; // 'object' — 区別できない

instanceof による絞り込み

クラスのインスタンスを判定するには instanceof を使います。

function formatDate(value: string | Date): string {
  if (value instanceof Date) {
    // この中では value は Date 型
    return value.toISOString();
  }
  // ここでは value は string 型
  return value;
}

instanceof はクラスベースのオブジェクト(DateErrorRegExp、独自クラスなど)に対して機能します。typeinterface で定義した型には使えません。型情報はコンパイル時に消えるため、実行時には存在しないからです。


in による絞り込み

オブジェクトが特定のプロパティを持っているかで絞り込みます。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    // この中では animal は Fish 型
    animal.swim();
  } else {
    // この中では animal は Bird 型
    animal.fly();
  }
}

'プロパティ名' in オブジェクト で、そのプロパティが存在するかを実行時にチェックします。TypeScriptはこのチェックの結果から型を推論します。


判別可能なユニオン(Discriminated Union)

最も強力で実用的なパターンです。ユニオンの各メンバーに共通のリテラル型プロパティを持たせることで、そのプロパティの値で型を判別します。

type LoadingState = {
  status: 'loading';
};
 
type SuccessState = {
  status: 'success';
  data: string[];
};
 
type ErrorState = {
  status: 'error';
  message: string;
};
 
type RequestState = LoadingState | SuccessState | ErrorState;

status プロパティがリテラル型で定義されているので、status の値を見れば型が確定します。

function renderState(state: RequestState): string {
  switch (state.status) {
    case 'loading':
      // state は LoadingState
      return '読み込み中...';
    case 'success':
      // state は SuccessState — data にアクセスできる
      return `${state.data.length}件`;
    case 'error':
      // state は ErrorState — message にアクセスできる
      return `エラー: ${state.message}`;
  }
}

このパターンはReactのstate管理やAPIレスポンスの処理で頻繁に使います。


網羅性チェック — never を使って漏れを検出する

判別可能なユニオンで、すべてのケースを処理したかをコンパイラにチェックさせられます。

function renderState(state: RequestState): string {
  switch (state.status) {
    case 'loading':
      return '読み込み中...';
    case 'success':
      return `${state.data.length}件`;
    case 'error':
      return `エラー: ${state.message}`;
    default: {
      // すべてのケースを処理していれば、ここには到達しない
      // state の型は never になる
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

もし将来 RequestState に新しい状態(例: 'timeout')を追加したのに case を書き忘れると、default に到達する可能性が生まれ、never への代入でコンパイルエラーになります。型で漏れを防げるわけです。


真偽値チェックによる絞り込み

nullundefined の可能性がある型は、真偽値チェックで絞り込めます。

function greet(name: string | null): string {
  if (name) {
    // この中では name は string(null と空文字は除外される)
    return `こんにちは、${name}さん`;
  }
  return 'こんにちは、ゲストさん';
}

ただし、0''(空文字)も falsy なので、数値や文字列で使うときは意図しない除外に注意してください。

function formatCount(count: number | null): string {
  if (count) {
    return `${count}件`;
  }
  // count が 0 のときもここに来てしまう
  return 'なし';
}
 
// 安全な書き方
function formatCount(count: number | null): string {
  if (count !== null) {
    return `${count}件`; // 0 も正しく表示される
  }
  return 'なし';
}

早期 return と throw による Narrowing

TypeScript は if ブロックの内側だけでなく、returnthrow で「ここには到達しない」と確定した後のコードも Narrowing します。

早期 return(Guard Clause)

function greet(name: string | null): string {
  if (name === null) {
    return '名前がありません'; // ここで return → 以降 name は null ではない
  }
  // この時点で name は string 型に絞り込まれている
  return `こんにちは、${name.toUpperCase()}さん`;
}

if ブロックの内部で絞り込む代わりに、先に弾いて return することで、本来の処理部分ではすでに型が確定した状態にできます。これが「Guard Clause(ガード節)」パターンです。

type User = {
  name: string;
  email: string | null;
  age: number | null;
};
 
function sendWelcomeEmail(user: User): void {
  if (user.email === null) return; // email が null なら終了
  if (user.age === null) return;   // age が null なら終了
 
  // ここでは user.email: string、user.age: number が確定
  console.log(`${user.email} に送信(${user.age}歳向けコンテンツ)`);
}

throw による Narrowing

throw は関数を中断するため、throw の後は「その変数は問題ない型」が確定します。

function requireString(val: unknown): string {
  if (typeof val !== 'string') {
    throw new TypeError(`string が必要ですが ${typeof val} が渡されました`);
  }
  // ここでは val は string 型
  return val.toUpperCase();
}

throw で例外を投げると、TypeScript はその後のコードには到達しないと判断するため、型が絞り込まれます。

アサーション関数パターン

throw を使った絞り込みを関数として切り出す場合、戻り値型に asserts を使うと型情報を呼び出し元に伝えられます。

function assertString(val: unknown): asserts val is string {
  if (typeof val !== 'string') {
    throw new TypeError(`string が必要です`);
  }
}
 
function process(input: unknown) {
  assertString(input); // ここで throw されなければ以降 input は string
  console.log(input.toUpperCase()); // OK — input: string
}

まとめ

型の絞り込み(Narrowing)は、ユニオン型を条件分岐で具体的な型に狭める仕組みです。

プリミティブの判定には typeof、クラスインスタンスには instanceof、プロパティの有無には in を使います。

最も実用的なのは判別可能なユニオンで、共通のリテラル型プロパティ(statustypekind など)の値で分岐することで、型安全にケースを処理できます。never を使った網羅性チェックで、ケースの追加漏れもコンパイル時に検出できます。

ここまでの01〜05章で、TypeScriptの型システムの基礎は一通り揃いました。次の章からは、関数の型付け、ジェネリクス、ユーティリティ型といったより発展的なトピックに入ります。