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からのデータを扱う際に特に役立つ。