TypeScript基礎講座 #4 Union / Literal / Narrowing

読了 10分

前回はオブジェクト型に名前を付けて再利用する方法を扱いました。今回は「複数の可能性のうちの1つ」を表す道具 — union型、literal型、narrowingを整理します。この3つは一緒に動いてTypeScriptの真の表現力を見せてくれます。

Union型 — 複数の可能性のうちの1つ #

値が2、3種類の型のいずれかになりうると表すときに使います。

union型
let value: string | number = 'こんにちは';
value = 42;          // ✓
value = true;        // 🚫 エラー: booleanは許可されない

string | numberは「この値はstringまたはnumber」という意味です。パイプ記号(|)で可能な型を並べます。

よく使うunionパターン #

nullまたはオブジェクト:

値またはnull
let user: User | null = null;
user = { id: 'u-1', name: '太郎' };

複数の入力形式を受け取る:

さまざまな入力を受け取る
function parseId(id: string | number): string {
  return String(id);
}

parseId(42);      // ✓
parseId('u-1');   // ✓

成功またはエラー:

結果型
type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };

function fetchData(): Result {
  // ...
  return { ok: true, value: 'レスポンスデータ' };
}

最後のパターンはdiscriminated union(タグ付きユニオン)と呼ばれ、非常によく使われます(下でさらに詳しく)。

Literal型 — 正確な値まで表す #

TypeScriptは「string」という抽象的な型だけでなく、「hello」という正確な値まで型として表せます。

literal型
let direction: 'left' | 'right' | 'up' | 'down' = 'left';
direction = 'right';     // ✓
direction = 'forward';   // 🚫 エラー: 4つのうち1つだけ可能

'left' | 'right' | 'up' | 'down'はstringの部分集合 — 正確にこの4つの文字列のいずれかだけを許可します。enumのより軽い代替案として#2で触れたのがこの手法です。

数値のliteralも可能です:

数値literal
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
const roll: DiceRoll = 4;     // ✓
const fail: DiceRoll = 7;     // 🚫

Literal + unionの強力さ #

literalとunionを一緒に使うと、JavaScriptでは表現しづらい精密な型が可能になります。

UI状態
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type Size = 'sm' | 'md' | 'lg';

function Button(props: { variant: ButtonVariant; size: Size; label: string }) {
  // ...
}

Button({ variant: 'primary', size: 'md', label: '確認' });   // ✓
Button({ variant: 'huge', size: 'md', label: '確認' });      // 🚫 'huge'は不可

APIのレスポンスstatus、コンポーネントのvariant、ローディング状態など、有限の選択肢があるあらゆるケースにうまくマッチします。

const assertion — 自動でliteral型にする #

オブジェクトや配列を作るときにas constを付けると、すべての値がliteral型として推論されます。

as const
const config = {
  mode: 'production',
  retries: 3,
} as const;

// config.mode: 'production' (string ではなく 'production' literal)
// config.retries: 3 (number ではなく 3 literal)

config.mode = 'development';  // 🚫 readonly + literal なので変更不可

as constで作った値はreadonly + 最も狭い型に固定されます。変わらない設定オブジェクトに合います。

配列でもよく使われます。

配列のas const
const colors = ['red', 'green', 'blue'] as const;
// colors: readonly ['red', 'green', 'blue']

type Color = typeof colors[number];
// Color = 'red' | 'green' | 'blue'

typeof colors[number]は「colors配列のすべての要素の型のunion」を意味します。このようにデータを1か所に置き、型をそこから自動で導くパターンは実戦でとてもよく使われます。データと型の同期負担がなくなります。

Narrowing — 分岐の中で型を絞り込む #

union型の値を受け取ると、可能な型が複数あるためすぐにすべてのメソッドを呼ぶことはできません。

unionの限界
function process(value: string | number): string {
  return value.toUpperCase();  // 🚫 numberにはtoUpperCaseがない
}

この問題を解くのがnarrowing(型の絞り込み)です。条件文や検査を通して、TypeScriptに「この分岐の中ではより狭い型」だと推論させるのです。

typeofで絞り込む — プリミティブ型 #

typeof narrowing
function process(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();    // ここではvalueはstringに絞り込まれる
  }
  return value.toFixed(2);          // ここではnumberに絞り込まれる
}

typeofはJavaScriptの一般的なキーワードですが、TypeScriptはこれを認識してその分岐の中で型を自動的に絞り込みます。

‘in’ 演算子で絞り込む — オブジェクト #

in narrowing
type Cat = { meow: () => void };
type Dog = { bark: () => void };

function makeSound(animal: Cat | Dog): void {
  if ('meow' in animal) {
    animal.meow();    // Catに絞り込まれる
  } else {
    animal.bark();    // Dogに絞り込まれる
  }
}

inキーワードでオブジェクトに特定のプロパティがあるかをチェック。あればそのプロパティを持つ型に絞り込まれます。

instanceofで絞り込む — クラス #

instanceof narrowing
function logError(error: Error | string): void {
  if (error instanceof Error) {
    console.log(error.message);     // Errorに絞り込まれる
    console.log(error.stack);
  } else {
    console.log(error);              // string
  }
}

Discriminated Union — タグで区別 #

オブジェクトunionで最も強力で、よく使われるパターンです。共通の「タグ」フィールドでどの形なのかを区別します。

discriminated union
type Loading = { status: 'loading' };
type Success = { status: 'success'; data: string };
type Failure = { status: 'failure'; error: string };

type State = Loading | Success | Failure;

function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return 'ロード中...';
    case 'success':
      return state.data;        // Successに絞り込まれてdataにアクセス可能
    case 'failure':
      return state.error;        // Failureに絞り込まれる
  }
}

statusフィールドの値でどの状態かを区別します。switch内の各分岐で型が自動的に絞り込まれ、その分岐に対応するフィールド(dataerror)に安全にアクセスできます。

このパターンは非同期状態、フォーム状態、メッセージの種類など、複数の形が混ざるあらゆるケースにうまくマッチします。覚えておく価値の大きいパターンです。

ユーザー定義のtype guard #

複雑なチェックを関数に抽出して再利用できます。

type guard 関数
type Cat = { type: 'cat'; meow: () => void };
type Dog = { type: 'dog'; bark: () => void };

function isCat(animal: Cat | Dog): animal is Cat {
  return animal.type === 'cat';
}

function makeSound(animal: Cat | Dog): void {
  if (isCat(animal)) {
    animal.meow();    // Catに絞り込まれる
  } else {
    animal.bark();
  }
}

animal is Catの部分が要点 — 「この関数がtrueを返したら仮引数はCat型だとコンパイラに伝える」という意味です。type predicateと呼びます。

複雑なチェックロジックを1か所に抽出できるため、コードの再利用に良いです。ただし利用者が責任を持ってチェックを正確に書く必要があります — コンパイラは関数の本体が本当にその型を保証するかまではチェックできません。

Truthiness narrowing #

JavaScriptのtruthy/falsyチェックもnarrowing効果があります。

nullチェック
function greet(name: string | null): string {
  if (name) {
    return `こんにちは, ${name.toUpperCase()}さん`;   // stringに絞り込まれる
  }
  return '名前がありません';
}
配列の空チェック
function first<T>(arr: T[] | undefined): T | undefined {
  if (arr && arr.length > 0) {
    return arr[0];     // T[]に絞り込まれる
  }
  return undefined;
}

このシンプルなifチェックが自然にnarrowingとして動作します。

neverによるexhaustivenessチェック #

discriminated unionのすべてのcaseを処理したかをコンパイルが保証するようにできます。

exhaustiveness check
type State = Loading | Success | Failure;

function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return 'ロード中...';
    case 'success':
      return state.data;
    case 'failure':
      return state.error;
    default:
      const _exhaustive: never = state;  // すべてのcaseを扱っていればここは到達不可
      return _exhaustive;
  }
}

もし将来Stateに新しい種類が追加されたのにswitchにcaseを追加し忘れたら、default分岐でその新しい型がneverではなく実際の型になってコンパイルエラーが発生します。漏れをコンパイル時に捕まえる強力なパターンです。

最初は不慣れに感じるかもしれませんが、大きなコードベースで真価を発揮します。覚えるほどではないですが、「ああ、こういうことができるんだ」くらいに知っておいてください。

よく使うnullableパターン #

T | nullまたはT | undefinedの形はあまりにもよく登場するので、別途別名を置くチームも多いです。

nullable 別名
type Nullable<T> = T | null;
type Optional<T> = T | undefined;

let user: Nullable<User> = null;
let result: Optional<string> = undefined;

Nullable<T>は#6で扱うジェネリクスのプレビューです。Tの位置にどんな型でも入れられます。

自分で試す — 非同期状態 #

ReactやJavaScriptの非同期コードによく出てくるパターンを型で表してみましょう。

async-state.ts
// データフェッチのすべての可能な状態
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

// ユーザー情報フェッチのシミュレーション
type User = { id: string; name: string };

let state: FetchState<User> = { status: 'idle' };

function startFetch(): void {
  state = { status: 'loading' };
}

function onSuccess(user: User): void {
  state = { status: 'success', data: user };
}

function onError(message: string): void {
  state = { status: 'error', error: message };
}

function describeState(s: FetchState<User>): string {
  switch (s.status) {
    case 'idle':
      return 'まだ開始していません';
    case 'loading':
      return '読み込み中...';
    case 'success':
      return `こんにちは、${s.data.name}さん!`;
    case 'error':
      return `エラー: ${s.error}`;
  }
}

startFetch();
console.log(describeState(state));  // 読み込み中...

onSuccess({ id: 'u-1', name: '太郎' });
console.log(describeState(state));  // こんにちは、太郎さん!

各状態でちょうどその状態で意味のあるフィールドだけにアクセス可能です。idleloadingではdataerrorもなく、successではdataだけ、errorではerrorだけ。間違ったフィールドアクセスがコンパイル時に止められること — これがdiscriminated unionの真価です。

よくある落とし穴 #

1. 型を絞り込んだ後にまた広がる #

絞り込みが解ける場合
function process(items: (string | number)[]): void {
  items.forEach(item => {
    if (typeof item === 'string') {
      // ここではstringに絞り込まれる
      setTimeout(() => {
        item.toUpperCase();   // 🚫 コールバックの中ではまたstring | numberに広がる可能性がある
      });
    }
  });
}

コールバック/クロージャに入ると絞り込みが解けることがあります。絞り込んだ値を変数に入れて使う方法で回避できます。

回避
if (typeof item === 'string') {
  const s = item;            // 絞り込んだ結果を変数に保管
  setTimeout(() => {
    s.toUpperCase();         // ✓
  });
}

2. literal union を書くときの引用符忘れ #

間違い
type Color = 'red' | 'green' | blue;  // 🚫 'blue'ではなく識別子として解釈される

literalは引用符の中の値です。引用符を抜くと識別子(変数)になり別の意味になります。最初によくする間違いです。

3. 狭すぎるliteral型 #

行き過ぎたliteral
function setMode(mode: 'dev' | 'prod'): void { /* ... */ }

const mode = 'dev';      // 型推論の結果: 'dev'? それとも string?
setMode(mode);           // 場合によってエラーの可能性

const mode = 'dev'の推論はたいていliteral型('dev')になりますが、let mode = 'dev'stringになります。必要ならas constや明示的な型注釈を使って意図を明確にする必要があります。

まとめ #

今回は「複数の可能性のうちの1つ」を扱う道具を整理しました。

  • union (A | B) — 複数の型のうちの1つ
  • literal型 ('red' | 'blue') — 正確な値を型に
  • as const — 自動でliteral + readonlyにする
  • narrowingtypeofininstanceof、discriminated union、ユーザーtype guardで分岐の中で型を絞り込み
  • discriminated union — オブジェクトunionの定石パターン (statusのようなタグフィールド + switch)
  • never — exhaustivenessチェック

次回「TypeScript基礎講座 #5 関数の型」では、関数の型をより精密に表現する方法 — オプショナル/デフォルト引数、関数オーバーロード、そしてジェネリクス入門を扱います。ジェネリクスは次々回の#6でさらに深く入っていくので、#5では軽い初対面くらいで始めます。

X