🔥

JSON.parse(JSON.stringify(x))に型をつけよう

2024/07/07に公開

はじめに

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のより詳しい紹介は以下の記事を参照してください。

本論

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

ここは少し解説が必要かもしれません。InvalidToNullInvalidValuenullに変換するための型です。ここに型パタラメータとしてユニオン型を渡すと、ユニオンのメンバーそれぞれに対して変換が行われ、それを元に新しいユニオン型が生成されます(「分配条件型」とも呼ばれます)。例えば、number | undefinednumber | nullになります。こんな具合です。

type A = InvalidToNull<number> // number
type B = InvalidToNull<undefined> // null
type C = InvalidToNull<number | undefined> // number | null

オブジェクト

オブジェクトについてはkeyが無視されるパターンに注意が必要です。以下の2つです。

  1. keyがsymbolである
  2. valueがInvalidValueである

よって、例えば以下の複雑なオブジェクトもbキーしか残りません。

JSON.stringify({
  [Symbol('a')]: '1',
  b: '2',
  c: undefined
}) // => '{"b":"2"}'

この仕様を正確に型に反映するためには、以下のような処置が必要です。

  1. 以下の場合はkeyを排除する
    • keyがsymbol型である
    • valueがInvalidValue型の場合
    • valueがInvalidValue型のみからなるユニオン型
  2. 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のドキュメントに記載された振る舞いのいくつかは無視しています。

  • InfinityNaNnullになる
    • これらは意図せず現れてしまうもので、型で扱っても実用上あまり意味がないと判断しました
  • Object()によって得られるBigIntSymbol
    • これらは正常な値として処理できると書かれていますが、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の扱いは妥協しました。

脚注
  1. 実際fetch APIを使う場合、通常はResponse.jsonでボディを取得すると思います。これはJSON.parseと同じ仕様に従います。 ↩︎

Discussion