⚡️

TypeScript で型安全かつ宣言的に switch をする

2025/03/08に公開

はじめに

TypeScript である値によって処理を分岐させる際に switch 文がありますが、

  • 式ではなく文なので宣言的に書けない(返り値を返せない)
  • 型に対して分岐を網羅していることを保証できない(linter で見ることはできる)
  • break を書かないといけない(そもそも switch の本質は処理の合流なので、break ありきの使い方も違う気がする)

というような問題点がありあまり好きではありません。オブジェクトリテラルで代替したり、 if で分岐した最後に ${変数} satisfies never をすることで型が網羅されていることをチェックするなどの方法がありましたが、上記の私の要件をキレイに満たせる手段がなかったため、switch の代替として使用できる関数を作成しました。

結論

こちらの condition 関数を使用することでこのようにキレイに書ける上、

  • 配列で条件を渡すことで複数一致に対応
  • 型が網羅的に分岐されていることの検査
  • 条件が重複していないいことの検査
  • 式なので宣言的に返り値を取得可能

ということができるようになりました。

/**
 * union 型の差分を取得する(string, number, symbol のみ対応)
 */
type Diff<T extends keyof never, U extends T> = (
  { [P in T]: P } 
  & { [P in U]: never }
  & { [x: keyof never]: never }
)[T]

/**
 * 条件分岐の型
 * case(mach, callback) で条件分岐を追加
 * else(callback) でデフォルトの処理を追加
 * done で結果を取得(型の網羅性を検査)
 */
type Condition<T extends keyof never, R> = [T] extends [never] ? {
  'done': R
} : ConditionItem<T, R>

type ConditionItem<T extends keyof never, R> = {
  case: <U extends T, A>(
    match: U | U[],
    callback: (matched: U) => A
  ) => Condition<Diff<T, U>, R | A>
  else: <A>(callback: (matched: T) => A) => R | A
}

/**
 * 型安全に Array.includes をするための関数
 * @param array 配列
 * @param input 比較対象(配列の型に含まれている必要はない)
 * @returns boolean
 */
const includes = <T extends ReadonlyArray<unknown>>(
  array: T,
  input: unknown
): input is T[number] => {
  return array.includes(input)
}

/**
 * match しない場合に true を返す
 * (Diff 型を返したいので判定逆転させている)
 */
const isUnmatch = <T extends keyof never, U extends T>(
  val: T,
  match: U | U[]
): val is Diff<T, U> => {
  if (Array.isArray(match)) {
    return !includes(match, val)
  }
  return val !== match
}

/**
 *  一度 match した後は実行セずに結果を引き継ぐ
 */
const matchedCondition = <T extends keyof never, R>(
  result: R
): Condition<T, R> => {
  const conditionItem: ConditionItem<T, R> = {
    case: <U extends T, A>(_: U | U[], __: (_: U) => A) =>
      matchedCondition<Diff<T, U>, R | A>(result),
    else: <A>(_: (_: T) => A) => result,
  }
  return {
    ...conditionItem,
    ...{ done: result },
  }
}

/**
 * メインロジック
 * (result を入力できないように condition を噛ましている)
 */
const conditionBody = <T extends keyof never, R>(
  val: T,
  result: R
): Condition<T, R> => {
  const conditionItem: ConditionItem<T, R> = {
    case: <U extends T, A>(match: U | U[], callback: (matched: U) => A) =>
      isUnmatch(val, match)
        ? conditionBody<Diff<T, U>, R | A>(val, result)
        : matchedCondition<Diff<T, U>, R | A>(callback(val as U)),
    else: <A>(callback: (matched: T) => A) => callback(val),
  }
  return {
    ...conditionItem,
    ...{ done: result },
  }
}

/**
 *  switch 文の代替として宣言的かつ型安全に条件分岐ができる関数
 *  最後に done プロパティを参照することで返り値を取得するとともに
 *  型の網羅性をチェックできる。例↓
 *    condition(val)
 *      .case('val1', () => '1' as const)
 *      .case(['val2', 'val3'], (val) => val)
 *      .done
 *  @param val マッチさせたい値。string | number | symbol の literal union であることが必要
 */
export const condition = <T extends keyof never>(val: T): Condition<T, never> =>
  conditionBody(val, undefined as never)

使い方

以下のように、union 型(string | number | symbol、つまりオブジェクトの key にできるものの部分型のみ扱えます)を持つとある定数を condition に渡すことで、case で各パターンについて条件分岐させられます。
条件を配列で渡すことで、複数のパターンについてまとめることが可能です(switch 文の case をまとめるのと同じ)。
最後に done プロパティを呼び出すことで返り値の取得及び型が網羅されたかのチェックができます。

type Test = 'a' | 'b' | 'c' | 'd'

const testFunc = (val: Test) => {
  return condition(val)
    .case('a', () => 1 as const)
    .case(['b', 'c'], (val) => val)
    .case('d', () => {
      console.log('d')
    })
    .done // 1 | 'b' | 'c' | void
}

0_normal.png
エディタでの型推論結果

以下のように else を呼び出すことで残っているパターン全てにマッチさせることもできるようにしています(switch 文の default)。
が、せっかく型の網羅検査ができるのに else に吸収させてしまうのは個人的にはアンチパターンだと思うため私は多分使いません。

type Test = 'a' | 'b' | 'c' | 'd'

const testFunc = (val: Test) => {
  return condition(val)
    .case('a', () => 1 as const)
    .case('b', (val) => val)
    .else(() => 2 as const) // 'c', 'd' がマッチ
}

型検査

網羅性

done プロパティは全ての条件を網羅した場合にしか存在しないようにしているため、done を最後に呼ぶことで型が網羅できているか検査されます。

1_all.png
'a' が足りていないことがわかります(Condition の Generics の 1 つ目)

条件重複

条件の型はチェーンされるたびに今まで判定したものが除外されて狭くなっていくため、同じ条件を複数回設定してしまったときに型エラーになるようになっています。

2_duplicate.png

返り値の型制約

返り値の型にも制約をつけたい場合は、done を satisfies で検査するといいでしょう。

3_return.png

部分ごとの解説

基本的には参考にさせていただいた↓の記事のものを元に、型をより厳密にしたり done プロパティを生やしたりしています。
https://qiita.com/hatakoya/items/018afbfb1bd45136618a

type Condition<T extends keyof never, R> = [T] extends [never] ? {
  'done': R
} : ConditionItem<T, R>

type ConditionItem<T extends keyof never, R> = {
  case: <U extends T, A>(
    match: U | U[],
    callback: (matched: U) => A
  ) => Condition<Diff<T, U>, R | A>
  else: <A>(callback: (matched: T) => A) => R | A
}

Diff 型はこちらの記事で紹介されていたものを使わせていただきました(元の記事だと DiffKey)。
union 型同士の差を取れます。すごい。(オブジェクトのキー限定)
https://qiita.com/typoerr/items/999fe07d079298c35e0c

Condition 型についてはプロパティの case 関数で再帰呼び出しをすることを想定したオブジェクトで、T は呼び出し時に渡された引数の型、R は返り値の型です。

case は第一引数で一致条件 match、第二引数で処理 callback を受け取ります。
case をチェーンするたびに T からは match の型 U が除外され、R には callback の返り値の型 A が追加されていくようになっています。

結果として T はどんどん狭くなっていき、R は広くなってきます。そして T が全て除外される = never になったときにだけ done プロパティを持つようにしています。

型ガード関数

const includes = <T extends ReadonlyArray<unknown>>(
  array: T,
  input: unknown
): input is T[number] => {
  return array.includes(input)
}

const isUnmatch = <T extends keyof never, U extends T>(
  val: T,
  match: U | U[]
): val is Diff<T, U> => {
  if (Array.isArray(match)) {
    return !includes(match, val)
  }
  return val !== match
}

includes は多分よくあるやつ。シンプルに型ガードかけているだけです。

isUnmatch は、 match が配列の場合は includes を呼び出し、そうでない場合は完全比較を行なっています。型ガードの都合上、 val is U とするよりも val is Diff<T, U> であった方が扱いやすかったので否定にしています。

メイン部分

const matchedCondition = <T extends keyof never, R>(
  result: R
): Condition<T, R> => {
  const conditionItem: ConditionItem<T, R> = {
    case: <U extends T, A>(_: U | U[], __: (_: U) => A) =>
      matchedCondition<Diff<T, U>, R | A>(result),
    else: <A>(_: (_: T) => A) => result,
  }
  return {
    ...conditionItem,
    ...{ done: result },
  }
}

const conditionBody = <T extends keyof never, R>(
  val: T,
  result: R
): Condition<T, R> => {
  const conditionItem: ConditionItem<T, R> = {
    case: <U extends T, A>(match: U | U[], callback: (matched: U) => A) =>
      isUnmatch(val, match)
        ? conditionBody<Diff<T, U>, R | A>(val, result)
        : matchedCondition<Diff<T, U>, R | A>(callback(val as U)),
    else: <A>(callback: (matched: T) => A) => callback(val),
  }
  return {
    ...conditionItem,
    ...{ done: result },
  }
}

export const condition = <T extends keyof never>(val: T): Condition<T, never> =>
  conditionBody(val, undefined as never)

conditionBody が本体です。match したら calback を実行し matchedCondition に引き継ぎます。型上 done を返していますが、一度でもマッチしたら matchedCondition の方から返されるので、ここの done が呼ばれることは実質的にはないはず。

matchedCondition は一度 match した後に通る処理で、callback などを全て無視し受け取った結果を後ろへ後ろへ引き継ぎつつ最後は done または else から返します。

感想

done を忘れずに最後に呼び出す必要があるのだけイケてないなぁと思いつつ、switch で break 書きまくってるよりはだいぶマシかということで自分に納得するようにしました。

英語の語彙がないので Lisp の条件分岐 cond を参考に condition としてみましたが、命名にピッタリ感がないのでいい英単語あったら教えてください。

参考にさせていただいた記事

https://zenn.dev/gladevise/articles/58b91b6c908ff3
https://qiita.com/hatakoya/items/018afbfb1bd45136618a
https://qiita.com/typoerr/items/999fe07d079298c35e0c

Discussion