型の絞り込み

TypeScript の型ガードや制御フロー分析で、ユニオン型をより具体的な型に絞り込む手法

TypeScript型システム

型の絞り込みとは

型の絞り込み (narrowing) は、if 文や typeof チェックといった実行時の判定を読み取って、コンパイラがその位置での型をより具体的な型へ狭めていく仕組みである。string | number | null のようなユニオン型は、そのままではどのメソッドも呼べない。分岐で可能性を潰した先ではじめて toUpperCasetoFixed が使えるようになる。この解析を制御フロー分析と呼ぶ。

function process(value: string | number | null) {
  // ここでは value は string | number | null
  if (value === null) return;
  // ここでは value は string | number (null が除外された)
  if (typeof value === 'string') {
    // ここでは value は string
    console.log(value.toUpperCase());
  } else {
    // ここでは value は number
    console.log(value.toFixed(2));
  }
}

絞り込みは型注釈を書き足す作業ではなく、人間が書いた判定をコンパイラが追跡する仕組みである。したがって「判定を書いたのに絞り込まれない」ときは、コンパイラが追跡できない書き方になっていることを疑う。

絞り込みの方法

typeof ガード

function format(value: string | number): string {
  if (typeof value === 'string') return value.trim();
  return value.toFixed(2); // number に絞り込まれる
}

プリミティブ型の判別に使う。typeof null'object' を返す言語仕様のため、null の除外は typeof では書けない。value === nullvalue != null を使う。

instanceof ガード

function handleError(err: Error | string) {
  if (err instanceof Error) {
    console.error(err.message, err.stack);
  } else {
    console.error(err);
  }
}

プロトタイプチェーンを辿る判定であるため、境界をまたいだ値では偽になることがある。iframe や Worker から渡されたオブジェクトは、その実行コンテキストの Error を継承しているため手元の Error とは一致しない。古い target: es5 へトランスパイルした場合も、組み込みクラスの継承が再現されず独自エラークラスの instanceof が偽になる (target: es5 自体が TypeScript 6 で非推奨になっている)。

in 演算子

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ('swim' in animal) animal.swim(); // Fish に絞り込み
  else animal.fly();                    // Bird に絞り込み
}

見ているのはプロパティの有無だけで、値の型は保証しない。省略可能なプロパティを持つ型では、in が真でも値が undefined のことがある。

判別可能なユニオン

共通のリテラル型フィールドを目印にする形である。後述の網羅性チェックと組み合わせられるため、分岐の抜けをコンパイル時に検出できる点が他の手法より強い。

type User = { id: number; name: string };

type ApiResponse =
  | { type: 'success'; data: User }
  | { type: 'error'; message: string }
  | { type: 'loading' };

function toLabel(response: ApiResponse): string {
  switch (response.type) {
    case 'success': return `${response.data.name} を読み込みました`;
    case 'error':   return `読み込み失敗: ${response.message}`;
    case 'loading': return '読み込み中';
  }
}

型述語 (カスタム型ガード)

type User = { id: number; name: string };

function isUser(value: unknown): value is User {
  return typeof value === 'object' && value !== null && 'id' in value && 'name' in value;
}

function handle(json: string) {
  const data: unknown = JSON.parse(json);
  if (isUser(data)) {
    console.log(data.name); // User 型に絞り込まれる
  }
}

value is User は「返り値が真ならこの引数はその型である」というコンパイラへの約束である。約束の中身が正しいかどうかは検査されない。そのため穴のある判定を書くと、型の上では安全に見える箇所が実行時に落ちる。

const data: unknown = JSON.parse('{"id":"a1","name":42}');
if (isUser(data)) {
  console.log(data.name.toUpperCase()); // 型は string・実行時は数値で例外
}

in はプロパティの有無しか見ないため、name が数値でも isUser は真を返す。フィールドの型まで確かめたいのであれば、判定を手書きするよりスキーマ検証ライブラリ (Zod 等) に書かせて、その出力型を使うほうが穴が生まれにくい。

TypeScript 5.5 以降は、真偽値を返す関数から型述語が推論される。filter に渡す関数が null を除くだけであれば、value is T と書かなくても結果が絞り込まれる。

const values = ['a', null, 'b'];
const names = values.filter(v => v !== null); // string[] に推論される
const upper: string[] = names.map(v => v.toUpperCase());

アサーション関数

function assertString(value: unknown): asserts value is string {
  if (typeof value !== 'string') throw new TypeError('文字列ではない');
}

function shout(input: unknown) {
  assertString(input);
  return input.toUpperCase(); // ここでは string
}

asserts value is string は「この関数から戻ってきた時点で型が確定している」という宣言で、例外を投げる検証関数と相性が良い。呼び出し対象の名前に明示的な型注釈が必要という制約があり、関数を変数に代入して呼ぶと TS2775 で弾かれる。

網羅性チェック

never 型を使って、switch 文で全ケースを処理していることをコンパイル時に保証する。

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

function handleStatus(status: 'active' | 'inactive' | 'suspended') {
  switch (status) {
    case 'active': return 'アクティブ';
    case 'inactive': return '非アクティブ';
    case 'suspended': return '停止中';
    default: return assertNever(status); // 新しいステータスを追加し忘れるとコンパイルエラー
  }
}

ユニオンに値を追加した瞬間に、対応漏れの switch がすべてコンパイルエラーとして浮かび上がる。実行時には例外として現れるため、型を無視して流れ込んだ想定外の値も取り逃がさない。

クロージャをまたぐ絞り込み

かつてはコールバックの中で絞り込みが失われた。TypeScript 5.4 (2024 年 3 月) でこの扱いが変わり、パラメーターや let 変数への最後の代入位置より後に作られた関数であれば、外側の絞り込みが中でも維持される。

function schedule(value: string | null) {
  if (value !== null) {
    setTimeout(() => {
      value.toUpperCase(); // 5.4 以降は string に絞り込まれる
    }, 100);
  }
}

維持されないのは、絞り込みの後にその変数へ代入がある場合と、巻き上げられる関数宣言から参照する場合である。

function scheduleThenReset(value: string | null) {
  if (value !== null) {
    setTimeout(() => {
      value.toUpperCase(); // error TS18047: 'value' is possibly 'null'.
    }, 100);
  }
  value = null; // この代入があるためクロージャ内の絞り込みが消える
}

回避策は以前から変わらない。絞り込んだ値をローカル変数へ写してからクロージャに渡す。

function scheduleSafely(value: string | null) {
  if (value !== null) {
    const v = value; // v は string
    setTimeout(() => { v.toUpperCase(); }, 100);
    value = null;
  }
}

絞り込みは実行時の安全までは保証しない

プロパティの絞り込みは、途中に関数呼び出しが入っても解除されない。呼び出した先でそのプロパティが消される可能性をコンパイラは追わないため、次のコードは型検査を通り、実行時に落ちる。

type Cache = { token?: string };

function refresh(cache: Cache) { delete cache.token; }

function use(cache: Cache) {
  if (cache.token !== undefined) {
    refresh(cache);
    return cache.token.length; // 型は string・実行時は undefined
  }
  return 0;
}

絞り込みが表しているのは「その行に到達した時点で判定が真だった」ことだけである。共有された可変オブジェクトをまたいで信用しない、判定した値はローカルに写して使う、の 2 点が実務上の防壁になる。

型ガードの種類

型ガード構文向いている対象
typeoftypeof x === 'string'プリミティブ型 (null は別に除外)
instanceofx instanceof Errorクラスのインスタンス
in'name' in xプロパティの有無 (値の型は不問)
判別可能なユニオンx.kind === 'circle'形の違うオブジェクトの共用体
真偽値・等価性if (x) / x === 'a'null と undefined の除外、リテラル型の特定
型述語function isUser(x): x is User外部から来た値の判定
アサーション関数function assert(x): asserts x is T例外を投げる検証

真偽値による絞り込みは 0 や空文字も同時に落とす。数値や文字列を含むユニオンから nullundefined だけを除きたいときは if (x != null) と書く。

使い分けの判断基準

  1. 自分のコードの中の分岐なら、判別可能なユニオンを最初に検討する。目印のフィールドを 1 つ持たせるだけで網羅性チェックまで手に入る。
  2. typeofinstanceof は前提が満たされていれば嘘をつかない。まずこの 2 つで書けないかを考える。
  3. 外部から来た値 (JSON・フォーム入力・環境変数) には型述語を手書きしない。検証ライブラリの出力型を使い、判定の穴を作らない。
  4. 絞り込みが効かないと感じたら、コンパイラの都合を疑う前に「その後で代入していないか」「巻き上げられる関数から見ていないか」を確認する。

この記事は役に立ちましたか?

関連用語

関連する記事