TypeScriptの幽霊型
幽霊型:追加の型変数を導入することで、型に含まれる情報を増やす技法。
今回は簡単なデコーダの例を通じ、幽霊型の用途を紹介する。
デコーダとは?
デコーダとは入力の型を解析する関数だ。
デコーダの型はこちら:
type Decoder<Input, Output> = (input: unknown) => Result<Output, Error>
まず出力の型を見ると、Resultであることがわかる。Resultとは、入力の処理に問題が発生すれば、エラーを返し、なければ結果を返す:
type Result<T> = Ok<T> | Error
type Ok<T> = { tag: 'ok', value: T }
type Error = { tag: 'error', text: string }
入力のinputはunknownであり、Outputはそうでないことから、デコーダとは入力の型を分析する関数だとわかる。
どうやって分析を行うかというと、narrowing を行う。
narrowing
JavaScriptとTypeScriptには動的に型を判明するtypeof関数がある。
if (typeof hoge === 'string') {
// ここでは hoge=文字列
}
これで動的な方法を使い、コンパイル時に型を推論できる。これを narrowing という。
では、narrowing を尽くし、複雑な型まで推論する方法を解説する。
簡単な幽霊型を作る
もう一度デコーダの型を見ましょう。
type Decoder<Input, Output> = (input: unknown) => Result<Output, Error>
早速だが、上記における幽霊型はどれか理解できましたか。しばらく考えてみてください。
ヒント:幽霊型は右辺に含まれていない。
前述した通り、inputはunknownだから、型がわからないはずだ。だが narrowing による型推論を使えば、inputの形が段々明確になる。
例えば:
const stringDecoder = (input: unknown) => {
if (typeof input === 'string') {
// `ok`は`Ok`型のコンストラクタ
return ok(input)
}
else {
// `error`は`Error`型のコンストラクタ
return error('Not a string.')
}
}
入力のtypeofがstringでなければエラーを返すので、返り値はエラーでなければ入力はやはり文字列だったということがわかる。
つまり、上記の型はDecoder<string, string>である。以下はその使い方:
stringDecoder('string') -> Ok<string>
stringDecoder(0) -> Error
オブジェクトの幽霊型
typeofを使えば文字列、数字やブール型のためのデコーダが簡単に作成できるようだ。あとは少し工夫してオブジェクト型のためのデコーダを作りましょう。
次に複数の問題を出すが、必ずやってみてください。ただ答えを見るだけでは理解が得られない。
オブジェクトのデコーダの型はこちら:
const objectDecoder = (decoders: D) => (input: unknown) => { ... }
objectDecoderの型の例:
const decoders = {
a: stringDecoder,
b: numberDecoder,
c: objectDecoder({ d: boolDecoder })
}
objectDecoder(decoders)
=> Decoder<{ // 入力の型
a: string,
b: number
c: {
d: bool
}
}, { // 出力の型
a: string,
b: number
c: {
d: bool
}
}>
第1問
decodersとは入力オブジェクトのキーに対するデコーダなら、型Dはなんだ?ヒント:オブジェクト型を使うといい。
答え
// ObjectKey = string | number | symbol
type D = { [Key in ObjectKey]: Decoder<unknown, unknown> };
Dとは任意のキーに対してデコーダを提供するオブジェクトの型である。
なぜ入力におけるフィールドではなく、任意のフィールドにするのかというと、inputはunknown型だからそもそも入力フィールドがわからない。
言い換えると、decodersのフィールドをもとにinputのフィールドを判明する。
次に、objectDecoderの返り値の型を考える。
そのために、二つの型を定義しないといけない:
- InputType: デコーダ型から入力型を抽出する型
- OutputType: 同様、デコーダ型から出力型を抽出する型
第2問
InputTypeとOutputTypeを定義してみてください。
答え
type InputType<D> = D extends Decoder<infer Input, unknown> ? Input : never;
type OutputType<D> = D extends Decoder<unknown, infer Output> ? Output : never;
まず、ここのextendsは===の意味に近い。とりあえずDはデコーダかどうか知りたいわけだ。
次にinferだが、文字通りコンパイラに対して「この型を推論してみて」という命令になっている。以下の条件二つが揃っていれば、inferを使用してください:
- 型の定義内に型パラメータを参照したい
- 型が事前にわからないため型パラメータとして渡せない
- 渡した型パラメータの一部だけ参照したい(例:
number[]のnumberなら(infer U)[]->U === number)
以上の例では、InputがわからないからこそInputTypeに推論してもらう。つまり、infer Input。
最後に、unknownの意味だが、単純に「こいつは無視したまえ」ということだ。
InputType型を文のように読み上げれば:InputTypeのパラメーターDはデコーダだったら、その入力の型を返す。
第3問
const objectDecoder = (decoders: D): R => ...の返り値Rの型はな〜んだ?
ヒント:InputTypeとOutputTypeはここで役に立つ。
ヒントその2:オブジェクト型のキーを参照したければkeyofを使うといい。
答え
まず、Rはデコーダである。したがって、本当に気になるのはtype R = Decoder<Input, Ouput>のInputとOutputだ。
もちろん、objectDecoderは文字通りオブジェクトをデコードする関数だから、入力も出力もきっとオブジェクトだろう。だが、フィールドの型を特定しないといけない。
そこで、keyofを使ってDからキーを取り出し、先ほど定義したInputTypeとOutputTypeを適用する:
const objectDecoder = (decoders: D): Decoder<
{ [Key in keyof D]: InputType<D[Key]> },
{ [Key in keyof D]: OutputType<D[Key]> }
>
=> ...
D[Key]という構文はあくまでobj[field] = ...の型版であり、「このキーに対するフィールドの型ちょうだい〜」と読むことができる。
まとめ
これでobjectDecoderの型の定義が完成した。関数本体はまだ書いていないが、かなり難しく幽霊型とほとんど関係がないので別の機会にしましょう。
この記事で紹介したデコーダを使うことで、anyかunknownから意味を持つ型をコンパイル時に取り出し、いつも通りTypeScriptの型チェックに機能してもらうことができる。APIからのデータを扱う際に特に役立つ。