⚡️

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

に公開
更新履歴
更新日 内容
2025-07-29 Exclude 型を使って Primitive 型全てに対応できるようにしました。
done をメソッドにしました。
2025-08-13 condition(val)<S>() で callback の返り値の型推論を渡せるようにしました。

はじめに

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

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

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

結論

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

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

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

type Switchable = string | number | bigint | symbol | boolean | undefined | null // Primitive

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

type ConditionItem<T extends Switchable, S, R> = {
  case: <U extends T, A extends S>(
    match: U | U[],
    callback: (matched: U) => A
  ) => Condition<Exclude<T, U>, S, R | A>
  else: <A extends S>(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 を返す
 * (Exclude した型を返したいので unmatch)
 */
const isUnmatch = <T extends Switchable, U extends T>(
  val: T,
  match: U | U[]
): val is Exclude<T, U> => {
  if (Array.isArray(match)) {
    return !includes(match, val)
  }
  return val !== match
}

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

/**
 * メインロジック
 * (result を入力できないように condition を噛ましている)
 */
const conditionBody = <T extends Switchable, S, R extends S>(
  val: T,
  result: R
): Condition<T, S, R> => {
  const conditionItem: ConditionItem<T, S, R> = {
    case: <U extends T, A extends S>(match: U | U[], callback: (matched: U) => A) =>
      isUnmatch(val, match)
        ? conditionBody<Exclude<T, U>, S, R | A>(val, result)
        : matchedCondition<Exclude<T, U>, S, R | A>(callback(val as U)),
    else: <A extends S>(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
 *  返り値の型を R に制約したい場合、 condition(val)<R>() のように型を指定する
 * 
 *  @param val マッチさせたい値。string | number | symbol の literal union であることが必要
 */
export const condition = <T extends Switchable>(val: T) =>
  Object.assign(
    <S>() => conditionBody<T, S, never>(val, undefined as never),
    conditionBody<T, unknown, never>(val, undefined as never)
  )

使い方

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

type Test = 1 | 'a' | 'b' | undefined 

const testFunc = (val: Test) => {
  return (
    condition(val)
      .case(1, () => 'matched 1' as const)
      .case(['a', 'b'], (v) => `matched ${v}` as const)
      .case(undefined, () => 'matched undefined' as const)
      .done()
  )
}

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 を最後に呼ぶことで型が網羅できているか検査されます。

type TestInput = 1 | 'a' | 'b' | undefined

export const testFunc = (val: Test) => {
  return condition(val)
    //.case(1, () => 'matched 1')
    .case(['a', 'b'], (v) => `matched ${v}`)
    .case(undefined, () => 'matched undefined')
    .done() // 1 が足りていないため、done プロパティが存在せずエラー
}

条件重複

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

type TestInput = 1 | 'a' | 'b' | undefined

export const testFunc = (val: Test) => {
  return condition(val)
    .case(1, () => 'matched 1')
    .case(['a', 'b'], (v) => `matched ${v}`)
    .case(1, () => 'matched undefined') // 1 は判定済みのため case の第一引数で type error
    .done()
}

返り値の型制約

返り値の型に制約をつけたい場合は、 condition(val)<Type>() のように高階関数呼び出しをしてジェネリクスを与えることで制約・推論をさせることができます。

type TestInput = 1 | 'a' | 'b' | undefined
type TestOutput = `matched ${string}`

export const testFunc = (val: Test) => {
  return condition(val)<TestOutput>()
    .case(1, () => 'matched 1') // TestOutput を extends した型になっていなければ type error
    .case(['a', 'b'], (v) => `matched ${v}`)
    .case(undefined, () => 'matched undefined')
    .done() // 'matched 1' | 'matched a' | 'matched b' | 'matched undefined' になる
    //(もしジェネリクスを設定しない場合返り値の推論がされず string に widening する)
}

部分ごとの解説

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

type Condition<T extends Switchable, S, R> = [T] extends [never] ? {
  'done': () => R
} : ConditionItem<T, S, R>

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

Condition 型についてはプロパティの case 関数で再帰呼び出しをすることを想定したオブジェクトで、T は呼び出し時に渡された引数の型、S は許容する返り値の型、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 Switchable, U extends T>(
  val: T,
  match: U | U[]
): val is Exclude<T, U> => {
  if (Array.isArray(match)) {
    return !includes(match, val)
  }
  return val !== match
}

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

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

メイン部分

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

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

export const condition = <T extends Switchable>(val: T) =>
  Object.assign(
    <S>() => conditionBody<T, S, never>(val, undefined as never),
    conditionBody<T, unknown, never>(val, undefined as never)
  )

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

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

condition が外部に export する関数本体になっています。conditionBody で生成したオブジェクトと、ジェネリクスを渡して conditionBody を生成する関数を合わせています。
ジェネリクスをカリー化して渡すような実装になっているのは、 condition<S>(val) のように、分岐対象 val とジェネリクス S を同時に受け取るような実装にしようとすると val の型を受け取るためのジェネリクスが使えなくなってしまい、 contition<typeof val, S>(val) のような冗長な記述が必要になってしまうためです。

引数 val の値とその型を受け取る関数呼び出しと返り値制約用のジェネリクスを受け取る関数呼び出しを分ける必要があったためこのような仕様になりました。

感想

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

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

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

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

Discussion