TypeScriptの幽霊型、その2
幽霊型とは、変数・関数内に使用されないまま、追加情報を提供する型である。用例として、型の不明なデータを確認するデコーダ(解析機)とともに使うと、デコードした型を幽霊型としてデコーダに保管できる。デコードしたデータを変えても、その元の形は型に残る。
前回の記事で紹介したように、幽霊型とデコーダを使い、元々anyかunknown型のオブジェクトからネストされたフィールドの型までTypeScriptに伝えることができた。
しかし、今までのデコーダでは入力と出力がいつも同一型だったが、今回紹介するmapデコーダは違う。配列のmap同様、デコーダのmapはデコードしたデータに変更を加えるために用いるので、出力は入力と異なる値になる。
デコーダとは何か
まずデコーダとは何か復習しましょう。一番単純なデコーダは、TypeScriptの基本的な型(数字や文字列など)をデコードするだけ。
// Result => 結果は値かエラーになることを示す型
// Error => 汎用的なエラー型およびエラーの値
type Decoder<Output, Input = Output> = (input: unknown) => Result<Output, Error>
Decoder<Output, Input = Output>の場合、InputとOutputは大抵同じ型になると予測し、デフォルトでInputにOutputを代入する。したがって、Decoder<string>はDecoder<string, string>に等しい。
// `ok`と`err`はそれぞれデータとエラーを含む`Result`のコンストラクタ
const stringDecoder: Decoder<string> = (input: unknown) => typeof input === "string" ? ok(input) : err('should be a string')
要は、typeofを利用し、入力は文字列だということが判明した。
mapデコーダ
では、文字列をデコードしたが、その文字列を数字に変換したいとする。いよいよ、mapの出番だ。
第1問
デコードされた文字列を数字に変換するデコーダだとすれば、mapはどの引数二つをもらわねばならない?
`map`の引数
- 文字列デコーダ
- 文字列から数字への変換を行う関数(例えば、
Number関数)
そのmapデコーダに文字列を渡せば数字(それともエラー)が戻されるはずだ。
map(stringDecoder, Number)(stringInput) => 数字?
第2問
mapの型はなんだ?ヒント:型パラメーターが必要になる。
`map`の型
// O1とO2はそれぞれ元のデコーダと最終デコーダの出力の型(O -> Output)
const map = <O1, O2, I = O1>(decoder: Decoder<O1, I>, f: (t: O1) => O2): Decoder<O2, I> => (input: unknown) => ...
fは値を型O1から型O2へ変換する関数だ。もちろん、O1とO2が必ずしも異なる型だとは限らない。例えば、fが数字を受け取り、その数字の倍を返す関数ならO1もO2もnumberである。
mapにデコーダを渡すと、出力されるデコーダの出力が違う型を持つ可能性がある。ならば、最終デコーダの入力と出力の型も違う。
これこそ、幽霊型(デコーダ入力の型)の醍醐味だと考える。つまり、unknown型の入力から始め、どんな変換を経てもunknownの後ろに隠れていた入力の真型を記録することができる。また、この記録は完全に自動的に行われる。
map関数の本体
const map = <T, U, I = T>(decoder: Decoder<T, I>, f: (t: T) => U): Decoder<U, I> =>
(input: unknown) =>
Result.map(decoder(input), f);
decoderの返り値を求め、fを適用するだけ。しかし、問題はResult.mapだ。
Result.mapの型を見れば、その用途がわかる:
// Result.ts
const map = <T, U, Error>(result: Result<T, Error>, f: (t: T) => U): Result<U, Error> => ...
Result.mapはResult内の値に関数を適用する。また、Resultはエラーであれば、そのエラーをそのまま返す。
第3問
ResultのサブタイプであるOkとErrorは以下の通りならば、Result.map関数の本体はどうなる:
type Ok<T> = { tag: 'ok', value: T }
type Error<E> = { tag: 'error', error: E }
`Result.map`関数の本体
const map = <T, U, Error>(result: Result<T, Error>, f (t: T) => U): Result<U, Error> => {
if (result.tag === 'ok')
return { tag: 'ok', value: f(result.value) }
}
// エラーの場合、そのまま返す
else {
return result
}
}
mapの用例
最後に、mapデコーダの用例と入出力の型の例はこちら:
デコードしたオブジェクトを文字列に変換
const urlObjectDecoder = objectDecoder({
scheme: stringDecoder,
host: stringDecoder,
path: stringDecoder
})
const urlObjectToStringDecoder = map(urlObjectDecoder, ({ scheme, host, path }) => `${scheme}://${host}/${path}`)
urlObjectToStringDecoderの入力の型:
type Input = {
scheme: string
host: string
path: string
}
urlObjectToStringDecoderの出力の型:
type Output = string
const urlString = urlObjectToStringDecoder({ scheme: 'https', host: 'www.example.com', path: 'path/to/something' })
urlStringの型はInput型と同値なので、Okが戻される:
urlString === { tag: 'ok', value: 'https://www.example.com/path/to/something' }
まとめ
今回は入出力の異なるデコーダに幽霊型を適用し、不定形なデータの変化を観察することができた。