⌨️

TypeScript 向けの軽量ランタイム JSON 型チェッカーを作った

3 min read 2

Typist JSON

Typist JSON という TypeScript で使える軽量なランタイム JSON 型チェッカーを作りました。

https://github.com/kawmra/typist-json

なぜ作ったか

世の中には io-ts, ajv, joiZod 等の有名な JSON バリデーションライブラリがたくさんあります。

これらのライブラリは JSON Schema がすでに利用できる場合や、単に JSON の型をチェックする以外に数値の範囲や文字列のパターンなども同時にチェックしたい場合には非常に便利ですが、一方でシンプルに JSON の型チェックさえできれば十分という場合には、やや大げさに感じます。

また、 joi や Zod が採用しているような TypeScript のインタフェース定義に似た書式でのスキーマ定義を拡張して、 TypeScript 4.1 で導入された Key RemappingTemplate 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 RemappingTemplate 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

check 関数を Type Guard として定義したことで型の絞り込みができるようになったのはよかったのですが、もし check 関数が false だった場合にどこがだめだったのかを知るすべがありません。シンプルさを取ってこのような API にしたのですが、なんらかの方法で追加の情報を通知できないか悩んでいます... なにかアイデアのある方、コメントいただけるとうれしいです!

Union型を使って実装すると良いかもしれません。

サンプル
const NameJson = j.object({
  name: j.string,
});

const result = NameJson.check(nameJson)

// 参考までに型を表記しておきます
type ResultType = typeof result
// { success: true; data: { name: string } } | { success: false; error: CheckError }

if( result.success ) {
  console.log(result.data); // dataを受け取れる
} else {
  console.log(result.error) // 検証結果を受け取れる
}

参考になれば幸いです。

コメント&アイデアの共有ありがとうございます!
実は、 union types を使ってご提案頂いたような仕様にすることも検討していました。
ですが、やっぱり元々の仕様と比較すると一旦結果を変数にいれなければいけなくなったり、 result.data のところでオリジナルの変数名(頂いたコメントの中でいう nameJson)が失われてしまうというのがネックで、諦めました 😔
しかしコメントを頂いてから思ったのですが、 check 関数とは別にこういう result のようなものを返す関数を別で提供するというのは良いかもしれないですね。検討してみます!ありがとうございました 🙇‍♂️

ログインするとコメントできます