TypeScript 向けの軽量ランタイム JSON 型チェッカーを作った
Typist JSON
Typist JSON という TypeScript で使える軽量なランタイム JSON 型チェッカーを作りました。
なぜ作ったか
世の中には io-ts
, ajv
, joi
や Zod
等の有名な JSON バリデーションライブラリがたくさんあります。
これらのライブラリは JSON Schema がすでに利用できる場合や、単に JSON の型をチェックする以外に数値の範囲や文字列のパターンなども同時にチェックしたい場合には非常に便利ですが、一方でシンプルに JSON の型チェックさえできれば十分という場合には、やや大げさに感じます。
また、 joi や Zod が採用しているような TypeScript のインタフェース定義に似た書式でのスキーマ定義を拡張して、 TypeScript 4.1 で導入された Key Remapping
と Template Literal Types
を使えば、オプショナルプロパティの記述がもっと直感的になるのではないかと思ったというのもあります(これについては後で説明します)。
自分がほしいと思ったものに完全に一致するものがなかったので、作ることにしました。
特徴
冒頭で紹介した Typist JSON には、次のような特徴があります。
- 軽量
- 直感的な API
- 型推論と Type Guard
軽量
シンプルな型チェックのみに機能を絞った結果、 minified + zipped で 619B (記事執筆時点) と、上であげたどのライブラリよりもファイルサイズが小さくなりました。
ファイルサイズを小さくすることを目的としていたわけではないので改良の余地はまだまだありそうですが、それでも十分小さいと言えそうです。
直感的な API
TypeScript のインタフェース定義に似た文法で JSON のスキーマ (JSON Schema ではありません) を定義することができます。
const NameJson = j.object({
firstname: j.string,
lastname: j.string,
});
const UserJson = j.object({
name: NameJson,
age: j.number,
"nickname?": j.string, // オプショナル プロパティ
});
nickname?
プロパティはオプショナルプロパティです。 TypeScript 4.1 で導入された Key Remapping
と Template Literal Types
を使用することで、特定のパターンにマッチするキーを持つプロパティを特別扱いすることができるようになったおかげで実現できました。
Typist JSON ではオプショナルプロパティと nullable なプロパティは厳密に区別されます。オプショナルプロパティとして定義したプロパティのみ、テスト対象のプロパティに存在していなくても有効だとみなされます。反対に、オプショナルなプロパティであったとしても明示的に nullable にしない限り、 null
は無効な値だとみなされます。
また、スキーマの循環参照にも対応しています。循環参照となるプロパティは値のチェッカーをアロー関数で包みます。
const FileJson = j.object({
filename: j.string,
});
const DirJson = j.object({
dirname: j.string,
entries: () => j.array(j.any([FileJson, DirJson])), // 循環参照
});
型推論と Type Guard
すべてのスキーマ(チェッカーとも呼んでいます)は check
関数を持っています。 check
関数は Type Guard
として機能するので、 check
関数が true
を返したスコープ内ではテスト対象の値は型が絞り込み (Narrowing
) されます。
const userJson = await fetch("/api/user")
.then(res => res.json());
if (UserJson.check(userJson)) {
// check 関数は Type Guard として働くので、
// ここでは userJson の型は以下のように絞り込まれています。
// {
// name: {
// firstname: string
// lastname: string
// }
// age: number
// nickname?: string | undefined
// }
}
また、 check
関数で型を絞り込む以外でスキーマで定義した型を TypeScript の型として使用したい場合のために、 JsonTypeOf
型を用意しています。
type UserJsonType = JsonTypeOf<typeof UserJson>
// UserJsonType は次のような型定義と同等です
// {
// name: {
// firstname: string
// lastname: string
// }
// age: number
// nickname?: string | undefined
// }
悩んでいるところ
check
関数を Type Guard
として定義したことで型の絞り込みができるようになったのはよかったのですが、もし check
関数が false
だった場合にどこがだめだったのかを知るすべがありません。シンプルさを取ってこのような API にしたのですが、なんらかの方法で追加の情報を通知できないか悩んでいます... なにかアイデアのある方、コメントいただけるとうれしいです!
Discussion
Union型を使って実装すると良いかもしれません。
参考になれば幸いです。
コメント&アイデアの共有ありがとうございます!
実は、 union types を使ってご提案頂いたような仕様にすることも検討していました。
ですが、やっぱり元々の仕様と比較すると一旦結果を変数にいれなければいけなくなったり、
result.data
のところでオリジナルの変数名(頂いたコメントの中でいうnameJson
)が失われてしまうというのがネックで、諦めました 😔しかしコメントを頂いてから思ったのですが、
check
関数とは別にこういうresult
のようなものを返す関数を別で提供するというのは良いかもしれないですね。検討してみます!ありがとうございました 🙇♂️