about-typescript-conditional-type

277
NO IMAGE

truncusではフロントエンドのライブラリで一部TypeScriptのConditional Typeをヘビーに使っているので、引継ぎあるいは説明資料の意味も兼ねてブログに書いておく。

Case1:ある型の継承関係にあるかどうかを返すconditional type

まずは練習として単純なものを。

例えば以下のようなインターフェースを定義していたとする。

interface Foo {
  val1: string;
  val2: number;
}

この時、継承関係にある型が渡されたら元の型となり、そうでなければneverになるconditional typeを考えてみよう。

Case1: Answer

type isFoo<T> = T extends Foo ? T : never

これは「TがFooと継承関係にある型であればT自身を、そうでなければneverを返す」というconditional typeになる。例えば以下の関数はFoo型の値を与えると特にエラーにはならないが、数値などを渡すとコンパイルエラーになる。

function isFoo<X> (x: isFoo<X>) {
  console.log(typeof x)
}

これは実のところさほど実用的なものではないというか、総称型のupper boundの指定とやっている事はほぼ同じなのだが、一応練習として。

Case2: 既知の相称型の型パラメーターの取得

truncusのフロントエンドライブラリでは以下のようなインターフェースを提供している。

interface Dictionary<T> {
  replace?: boolean
  value?: { [key: string]: T }
}

interface Sequence<T> {
  replace?: boolean
  value?: T[]
}

こういった既知の総称型を与えると、その総称型の型パラメーターの型を返す(不明な型にはneverを返す)condional typeというものを考えてみよう。

※実際のライブラリではもっと様々な型が絡んで複雑な型定義になっている。

Case2: Answer

type extractTypeParam<Type> = Type extends Dictionary<infer X>
  ? X
  : Type extends Sequence<infer X>
  ? X
  : never

inferキーワードはconditional typeの中で総称型のパラメーターをキャプチャーするものとして働く。つまり上記の例は「TypeがDictionaryを継承している場合、キャプチャーした型パラメーターXを返す。そうでない場合、TypeがSequenceを継承している場合、キャプチャーした型パラメーターXを返す。そうでない場合、neverを返す。」というように読める。

Case3: 型変換関数とconditional type

上記の例の応用になるが、以下の仕様の関数を書いてみよう。

  • DictionaryはMapに変換する。
  • SequenceはT[]に変換する。
  • どちらでもないなら例外を送出する。

この時の関数の戻り値の型は、conditional typeを使うとかなりスマートに書ける。

Case3: Answer

type extractTypeParam<Type> = Type extends Dictionary<infer X>
  ? X
  : Type extends Sequence<infer X>
    ? X
    : never

type mappingCollection<Type> = Type extends Dictionary<infer X>
  ? Map<string, X>
  : Type extends Sequence<infer X>
    ? X[]
    : never

function convertCollection<X> (x: X): mappingCollection<X> {
  if ('value' in x) {
    const v = (x as any).value
    if (Array.isArray(v)) {
      return v as any
    } else if (typeof v === 'object' && v !== null) {
      const ret = new Map<string, extractTypeParam<X>>()
      for (const p in v) {
        ret.set(p, v[p])
      }
      return ret as any
    }
  }
  throw new Error()
}

型推論の都合上途中でanyにキャストしてしまっているが、実際にこの関数を使う方ではしっかりと正しい型を得ることが出来る。

const a = { value: { val1: 'a', val2: 1 } }
const a2 = convertCollection(a)

この例では、a2の型はMapにしっかり推論される。

Case4: 一見して紛らわしい型の条件分岐

ここまで使っていた例を、以下のように拡張しよう。

interface Dictionary<T> {
  replace?: boolean
  value?: { [key: string]: T }
}

interface Sequence<T> {
  replace?: boolean
  value?: T[]
}

interface Foo<T> {
  value: T
}

interface FooDictionary<T> {
  replace?: boolean
  value?: { [key: string]: Foo<T> }
}

この時、何らかの理由でFooDictionaryと通常のDictionaryは分けて扱いたいとする。例えばFooDictionaryはMapではなく{ [key: string]: Foo }として変換したいというような場合、どのようなconditional typeを書けばよいか。

Case4: Answer

type mappingCollection<Type> = Type extends FooDictionary<infer X>
? { [key: string]: Foo<X> }
: Type extends Dictionary<infer X>
  ? Map<string, X>
  : Type extends Sequence<infer X>
    ? X[]
    : never

このような場合、先に具体性のある型と合致しているかを見ることで期待した結果を得ることが出来る。


このようにconditional typeはかなり強力な機構なので、特にフレームワークやライブラリの開発では力を発揮する。それらの開発に従事する際には覚えておいて損はないだろう。