JSON.parse(JSON.stringify(x))に型をつけよう
はじめに
WebバックエンドとクライアントをともにTypeScriptで書くとします。また、バックエンドではJSON.stringify
で値をシリアライズし、クライアントサイドではJSON.parse
相当の処理でレスポンスボディを取得すると仮定しましょう[1]。このとき、JSON.stringify
の挙動がわかれば、実際にクライアントにどのような値が返ってきうるかを型で表現できるはずです。例えば、
console.log(JSON.stringify({ a: undefined })) // => "{}"
となるので、{ a: undefined }
の型をJSON.stringify
してからJSON.parse
した値の型は{}
とするべきです。
本稿では、JSON.stringify
の仕様に沿ってそのような型を定義する方法について解説していきます。また、その際の制限についても最後に軽く触れます。
背景
本稿の概要を理解をするためには不要ですが、この節ではそれを試みることになった背景についてお話しします。
Hono
というWebアプリケーションフレームワークがあります。このフレームワークはクライアントに型を共有する機能を持っています。この機能を使うと、クライアント側でAPIのリクエスト/レスポンスの型を利用できます。これ自体はとても魅力的な機能ですが、Honoのv4.4.11まではレスポンスの型付けがJSON.stringify
の仕様に従っておらず不十分なものにとどまっていました。筆者はこれを修正し、Hono v4.4.12の一部として無事リリースしていただける運びとなりました。
Hono RPCのより詳しい紹介は以下の記事を参照してください。
- https://zenn.dev/yusukebe/articles/a00721f8b3b92e
- https://zenn.dev/mshaka/articles/732a79cd29e173 (拙論)
本論
JSON.stringify
の仕様については、MDNに詳しく書かれています。
定義する型の名前はHonoに倣いJSONParsed
とし、それを徐々に拡張していきましょう。なお、以下のコードにはHonoに取り込まれたコードと一部異なる部分がありますが、挙動に違いはありません。
type JSONParsed<T> = //...
primitives
boolean, number, stringはJSONのプリミティブ型として扱われます。これらの型はそのままJSONに変換されます。なお、bigIntについては制約の部分で言及します。
type JSONPrimitive = boolean | number | string
type JSONParsed<T> = T extends JSONPrimitive ? T : never
toJSONメソッド
toJSONメソッドを持っていると、その戻り値をさらにJSON.stringify
で処理します。ただし、戻り値がtoJSON
を持っていても特別扱いはされず、無効な値を持つkeyとして排除されます(詳しい説明は後述)。
type JSONParsed<T> = T extends JSONPrimitive
? J
: T extends { toJSON(): infer J }
? (() => J) extends () => { toJSON(): unknown }
? Omit<J, 'toJSON'>
: JSONParsed<J>
: never
無効な値
undefined, function, symbol(以下ではまとめてInvalidValue
と呼ぶことにします)をそのままJSON.stringify
に渡すと不正な値としてundefined
が返ってきます。JSON.parse(undefined)
は例外を投げるので、さしあたりnever
型として扱います。
type InvalidValue = undefined | ((..args: unknown[]) => unknown) | symbol
type JSONParsed<T> = T extends JSONPrimitive
? J
// 中略
: T extends InvalidValue
? never
: never
配列
配列は基本的にその要素を再帰的に処理しますが、要素がInvalidValueの場合はnull
に変換されます。従って、型も以下のように変換する必要があります。
-
undefined[]
->null[]
-
(number | undefined)[]
->(number | null)[]
type InvalidToNull<T> = T extends InvalidValue ? null : T
type JSONParsed<T> = T extends JSONPrimitive
? J
// 中略
: T extends <infer U>[]
? JSONParsed<InvalidToNull<U>>[]
: never
ここは少し解説が必要かもしれません。InvalidToNull
はInvalidValue
をnull
に変換するための型です。ここに型パタラメータとしてユニオン型を渡すと、ユニオンのメンバーそれぞれに対して変換が行われ、それを元に新しいユニオン型が生成されます(「分配条件型」とも呼ばれます)。例えば、number | undefined
はnumber | null
になります。こんな具合です。
type A = InvalidToNull<number> // number
type B = InvalidToNull<undefined> // null
type C = InvalidToNull<number | undefined> // number | null
オブジェクト
オブジェクトについてはkeyが無視されるパターンに注意が必要です。以下の2つです。
- keyがsymbolである
- valueがInvalidValueである
よって、例えば以下の複雑なオブジェクトもb
キーしか残りません。
JSON.stringify({
[Symbol('a')]: '1',
b: '2',
c: undefined
}) // => '{"b":"2"}'
この仕様を正確に型に反映するためには、以下のような処置が必要です。
- 以下の場合はkeyを排除する
- keyがsymbol型である
- valueがInvalidValue型の場合
- valueがInvalidValue型のみからなるユニオン型
- valueがInvalidValueとそれ以外の値のユニオンの場合は、keyが落ちる可能性があるのでoptional keyとする
ただ、2に関してはHonoで実装したときにパフォーマンス問題を引き起こしたため、optional keyにすることは諦め、T | undefined
として処理することとします。この点についても制約の節で改めて触れます。
type OmitSymbolKeys<T> = { [K in keyof T as K extends symbol ? never : K]: T[K] }
type IsInvalid<T> = T extends InvalidJSONValue ? true : false
type JSONParsed<T> = T extends JSONPrimitive
? J
// 中略
: T extends object
? {
[K in keyof OmitSymbolKeys<T> as IsInvalid<T[K]> extends true
? never
: K]: boolean extends IsInvalid<T[K]> ? JSONParsed<T[K]> | undefined : JSONParsed<T[K]>
}
: never
まず、key指定の部分を見てみましょう。OmitSymbolKeys
はその名の通りsymbol型のkeyを排除するための型です。as
以下はそれに加えて保持するkeyの指定を行っているのですが、ここでのIsInvalid
はややテクニカルな使われ方をしています。前述したInvalidToNull
と同様に、IsInvalid
もユニオン型のメンバーそれぞれに対して処理を行います。では、IsInvalid<T[K]> extends true
が成り立つのはどんな場合でしょうか。それは全てのメンバーがInvalidValue
のときです。例えば、IsInvalid<undefined | symbol>
の場合は、true | true
が返ってくるので、これは簡約されてtrue
となります。これによって、その場合はkeyを無視することができます。
次に値の型について見てみましょう。valueの型の指定において、boolean extends IsInvalid<T[K]>
という条件式を使っています。これが成り立つのは、ユニオンのメンバーの少なくとも1つがInvalidValue
型のときです。例えば、IsInvalid<undefined | number>
はtrue | false
、つまりboolean
となります。他方、IsInvalid<number>
やIsInvalid<number | string>
はfalse
となります。この条件式によって、InvalidValue
型を含む(が、全てがInvalidValue
というわけではない)ユニオン型の場合はundefined
とのユニオン型として処理されることになります。
tuple
tupleはTypeScriptの概念なのでJSON.stringify
とは本来は無関係ですが、正しく扱ってくれるととても便利なので対応します。もちろんランタイムでは単なる配列なので、配列の仕様に従います。
type JSONParsed<T> = T extends JSONPrimitive
? J
// 中略
: T extends []
? []
: T extends [infer U, ...infer Rest]
? [JSONParsed<InvalidToNull<U>>, ...JSONParsed<<Rest>>]
: never
tupleの先頭の型に対してInvalidToNull
をかませてからJSONParsed
を適用し、残りの要素に対してはそのまま再帰的にJSONParsed
を使っています。[]
の処理がないと、例えば[number, string]
が[number, string, ...never[]]
となってしまうので注意が必要です。
制約
MDNのドキュメントに記載された振る舞いのいくつかは無視しています。
-
Infinity
とNaN
はnull
になる- これらは意図せず現れてしまうもので、型で扱っても実用上あまり意味がないと判断しました
-
Object()
によって得られるBigInt
とSymbol
- これらは正常な値として処理できると書かれていますが、
Object
で生成した値はany
型とされてしまうので無視しました
- これらは正常な値として処理できると書かれていますが、
- 列挙可能プロパティの扱い
- HonoではSet/Mapも処理はしていますが、これを完璧に型のレイヤーで対処するのは難しいと思いますし、かなり行儀の悪いコードでないと問題にならないと思います。
また、上で触れたoptional keyについてですが、厳密に実装するとHonoではかなり大きな型変換の中で使われるケースがあり、その場合に型検査が終了せずエラーになるということがありました。しかし、小さなケースでは特に問題にはなりませんので、参考までに厳密な処理を示しておきます。
type Flatten = { [K in keyof T]: T[K] }
type JSONParsed<T> = T extends JSONPrimitive
? J
// 中略
: T extends object
? Flatten<{
[K in keyof OmitSymbolKeys<T> as boolean extends IsInvalid<T[K]>
? K
: never]?: JSONParsed<T[K]>
} & {
[K in keyof OmitSymbolKeys<T> as IsInvalid<T[K]> extends false
? K
: never]: JSONParsed<T[K]>
}>
: never
optional keyとrequired key両方を持つ型を一度に生成することができないため、処理を2つに分けてintersectionをとっています。また、一見等しく思われるような{ a: string } & { b: number }
と{ a: string, b: number }
は異なる型として扱われるため、Flatten
を使ってintersectionを消しています。おそらく、このようにしてobjectの型に対する走査が複数行われることになり、そのせいでパフォーマンスが悪化したと考えられます。
厳密にいえば、{ a: string | undefined }
の値と{ a?: string }
の値とは異なる振る舞いが期待されます。例えば、Object.keys({ a: undefined })
は['a']
を返しますが、Object.keys({})
は[]
を返します({}
は{ a?: string }
として型付けできます)。しかし、Object.keys
自体には厳密な型はついていませんし、ほとんどの実用的なケースでは問題にならないと判断し、optional keyの扱いは妥協しました。
-
実際fetch APIを使う場合、通常は
Response.json
でボディを取得すると思います。これはJSON.parseと同じ仕様に従います。 ↩︎
Discussion