やりたいこと
次のような[string, any]
な型が複数あるとき、
type Foo = ['foo', string]
type Bar = ['bar', number]
type Buz = ['buz', boolean]
これらをもとにして、次のようなオブジェクト型を導き出したい。
type TheObject = {
foo: string
bar: number
buz: boolean
}
やりかた
TupleToObject
というタプル型からオブジェクト型にマッピングする型を便宜上作ると良い。
type TupleToObject<T extends [string, any]> = {
[K in T[0]]: Extract<T, [K, any]>[1]
}
この型の使い方は、TupleToObject
の型パラメータにタプル型をユニオン型で渡す。
type TheObject = TupleToObject<Foo | Bar | Buz>
動作確認
期待通りのオブジェクト型が導出されているか確認してみる。
// これがコンパイルに通り、
const obj1: TheObject = { foo: '', bar: 0, buz: false }
// これがコンパイルエラーになれば期待通り。
const obj2: TheObject = { foo: 0, bar: false, buz: '' }
TupleToObject
の仕組み
どのようにTupleToObject
が動くのかを見てみよう。
TupleToObject
の定義をもう一度見てみると、Extract
を使っているのが分かる。
type TupleToObject<T extends [string, any]> = {
[K in T[0]]: Extract<T, [K, any]>[1]
}
Extract
がタプルをオブジェクトに変換する上で、重要な役割なのだが、それを説明する前に、TupleToObject
の失敗作を先に見せておきたい。
最初に作った失敗版TupleToObject
は次のような定義になっていた。
// 失敗作
type TupleToObject<T extends [string, any]> = {
[K in T[0]]: T[1]
}
タプルの要素0をキーに、要素1を値にといった具合に率直な定義になっており、一見するとこれでも期待通りのオブジェクト型が導出できそうだ。しかし、実際使ってみると全然ダメだった。
この失敗作にTupleToObject<Foo | Bar | Buz>
を当てはめてみると、値がすべてユニオン型になってしまったのだ。
type TheObject = {
foo: string | number | boolean;
bar: string | number | boolean;
buz: string | number | boolean;
}
そのため、コンパイルエラーにしたい次のようなコードも、コンパイルが通ってしまった。
const obj2: TheObject = { foo: 0, bar: false, buz: '' }
この問題を解決してくれるのが、最初に重要だと述べたExtract
だった。
type Extract<T, U>
Extract
は2つの型パラメータを取り、T型がU型に代入可能であれば、その型を返し、そうでなければnever
を返すConditional Typeだ。
例えば、次のようなコードではX
の型はstring
になる。というのも、string, number, boolean
のうち、string
に代入できるのは、string
だけだからだ。
type X = Extract<string | number | boolean, string>
もちろん、タプル型にも応用できて、3つのタプルから特定のタプルを抽出することができる。
type X = Extract<['foo', string] |
['bar', number] |
['buz', boolean], ['foo', any]>
ここでのX
の型は['foo', string]
になる。
この抽出の仕掛けを失敗作に適用することで、キーに該当する値の型を抽出することができるようになる。
type TupleToObject<T extends [string, any]> = {
// 失敗作
// [K in T[0]]: T[1]
[K in T[0]]: Extract<T, [K, any]>[1]
}
応用
キーバリューのタプルからオブジェクトの型を作る方法を応用すると、オブジェクトを合成したオブジェクトの型を作ることもできる。
const increment = {
name: 'increment',
calculate: (value: number): number => value + 1
} as const
const add = {
name: 'add',
calculate: (a: number, b: number): number => a + b
} as const
const sum = {
name: 'sum',
calculate: (...nums: number[]): number => nums.reduce((a, b) => a + b, 0)
} as const
interface Calculate<Name extends string = string> {
name: Name
calculate(...nums: number[]): number
}
type CalculatorOf<Method extends Calculate> = {
[Name in Method['name']]: Extract<Method, Calculate<Name>>['calculate']
}
type TheCalculator = CalculatorOf<typeof increment | typeof add | typeof sum>
const calculator: TheCalculator = {
[increment.name]: increment.calculate,
[add.name]: add.calculate,
[sum.name]: sum.calculate,
}
calculator.increment(1)
calculator.add(1, 2)
calculator.sum(1, 2, 3, 4, 5)
calculator.increment(1, 1) // コンパイルエラー: 引数が多い
calculator.add(1) // コンパイルエラー: 引数が足りない
calculator.sum(true, false, 'str') // コンパイルエラー: 引数の型が正しくない