zodを使ってJSON.parseしたオブジェクトをvalidationして型安全にしたい


やりたいこと

JSON.parse(json)して得られたオブジェクトをTypescriptの型でバリデーションしたい

なぜやりたいか

APIなどからjson文字列を受け取る際、JSON.parse(json)のようにして文字列を解析してjavascriptのオブジェクトを構築しますが、このJSON.parseの返り値の型がanyなので、変数に代入する型をいくら指定してもしれっと代入可能になってしまいます。
そのため、↓のようなコードはコンパイルは通ってもランタイムでエラーになってしまいます。

type Post = {
  id: number
  title: string
  content: string
}

const post: Post = JSON.parse("{ id: 1, title: 'title' }") // JSON.parseの返り値がanyなので普通に代入できてしまう。

post.content.length // => contentはundefinedなのでerror

せっかくTypescriptで型のある開発を満喫していても、anyの前にはすべてが崩壊します。

Typescriptは実行時の型情報の提供は目指していないので、Proposalな仕様であるdecorator等を使ってアノテーションを記載するか、それ以外の方法を使って実行時のvalidationに対応する必要があります。

Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.

やったこと

validationライブラリであるzodを使って、JSON.parseしたオブジェクトをvalidationするサンプルコードを書きました。

zodとは

typescript firstなvalidationライブラリです。類似のライブラリにyupio-tsなどがありますが、Typescriptへの親和性や、重複するスキーマ・型宣言の排除を目標に様々な機能が設計されています。
使い方も非常にシンプルで、ファイルサイズもminifyされたファイルが8kbと非常に小さいのも良い点です。
また、Next.jsをラッパーしたフルスタックフレームワークで最近話題のblitz.jsのvalidationライブラリとして採用されていたりと、なかなか注目のライブラリとなっています。

zodの仕様については公式リポジトリか、こちらの記事が日本語で非常によくまとまっているので一度見てみてください。

実際のコード

import { z } from 'zod'

const User = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email()
})

type User = z.infer<typeof User>

const Post = z.object({
    id: z.number(),
    title: z.string(),
    user: User
})

type Post = z.infer<typeof Post>

const validJson = JSON.stringify({
  "id": 1,
  "title": 'title',
  "user": {
    "id": 1,
    "name": "name",
    "email": "[email protected]"
  }
})

const invalidJson = JSON.stringify({
  "id": 1,
  "title": 'title',
  "user": {
    "id": 1,
    // "name": "name", name should be required
    "email": "[email protected]"
  }
})

try {
  const validPost: Post = Post.parse(JSON.parse(validJson))
  console.log('validPost parse success')
  console.log(validPost)
  const invalidPost: Post = Post.parse(JSON.parse(invalidJson))
  console.log('invalidPost parse success') // not run
  console.log(invalidPost) // not run
} catch (e) {
  console.log(e)
  /**
  ZodError: [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": [
        "user",
        "name"
      ],
      "message": "Required"
    }
  ]
  **/
}

まず、以下のコードでvalidation schemaを定義しています。

const User = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email()
})

上記はオブジェクト型のschemaを定義していますが、もちろんプリミティブ型や、Typescriptで定義されているUtilityTypesライクな pick omit partialなど、他にも様々な便利な関数が定義されています。

ネストしたオブジェクトの型などももちろん柔軟に定義できます

const Post = z.object({
    id: z.number(),
    title: z.string(),
    user: User // 先程作成したvalidation schema
})

続いて、先程定義したvalidation schemaから型を生成するコードです

type Post = z.infer<typeof Post>

型定義とvalidation schemaの定義が1度に行えるので、重複管理にならなくて嬉しいですね。

そして、実際に検証を行うコードは以下で、
parseに渡された引数を検証し、問題なければ Post型としてリターンします。

const validPost: Post = Post.parse(JSON.parse(validJson))

検証に引っかかった場合はZodErrorオブジェクトがthrowされます

try {
    const invalidPost = Post.parse(JSON.parse(invalidJson))
} catch (e) {
    console.log(e)
    /**
    ZodError: [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "undefined",
        "path": [
          "user",
          "name"
        ],
        "message": "Required"
      }
    ]
     **/
}

parseを使わずsafeParse関数という使うと、throwされず、検証結果とデータ/エラーを返します。

Post.safeParse(JSON.pase(validJson))
// => { success: true; data: { // ... } }

Post.safeParse(JSON.parse(invalidJson))
// => { success: false; error: ZodError }

以上で、実現したかったJSON.parseしたオブジェクトをvalidationして型安全にするという目的は達成できました。
型があるっていいですね。

終わりに

以下の記事でも書かれているように、ZodのSchemaと既存の型のダブルメンテ問題など実運用をする上での細かい対応や注意するべき点があったり、最新のver3は現在betaだったりと、全員に進められるものというわけではありませんが、個人的にAPIのシンプルさや設計思想はとても気に入っているので、継続して使っていきたいなと思いました。