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' }
まとめ
今回は入出力の異なるデコーダに幽霊型を適用し、不定形なデータの変化を観察することができた。