typescript

【TypeScript】Zod でスキーマ宣言とバリデーションを実施する

[Zod](https://github.com/colinhacks/zod) は TypeScript first でスキーマ宣言とバリデーションを実施するためのライブラリです。 一度バリデータを宣言すれば、Zod が自動的に TypeScript の型を推論してくれるという特徴があります。このおかげで重複した型宣言を排除できます。 また、Zod はエコシステムも多く存在しており、OpenApi、Nest.js、Prisma、react-hook-form などと組み合わせて使うことができます。

Zod は TypeScript first でスキーマ宣言とバリデーションを実施するためのライブラリです。

一度バリデータを宣言すれば、Zod が自動的に TypeScript の型を推論してくれるという特徴があります。このおかげで重複した型宣言を排除できます。

また Zod はエコシステムも多く存在しており、OpenApi、Nest.js、Prisma、react-hook-form などと組み合わせて使うことができます。

https://github.com/colinhacks/zod#ecosystem

簡単な使い方

まずは 1 番簡単な使い方から見ていきましょう。Zod は TypeScript 4.1 以上が必要です。また strict モードを有効にする必要があります。

// tsconfig.json
{
  // ...
  "compilerOptions": {
    // ...
    "strict": true
  }
}

以下のコマンドで Zod をインストールします。

npm install zod       # npm
yarn add zod          # yarn
pnpm add zod          # pnpm

1 番簡単な string 型のスキーマを定義します。

import { z, ZodError } from 'zod' // ①
 
const usernameSchema = z.string() // ②
type Username = z.infer<typeof usernameSchema> // ③
// type Username = string
 
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest
 
  it('should work', () => {
    expect(usernameSchema.parse('john')).toBe('john') // ④
  })
 
  it('should fail', () => {
    expect(() => usernameSchema.parse(1)).toThrow(ZodError) // ⑤
  })
}

zod モジュールから z をインポートします。z はスキーマを定義するための関数を持っています。

z.string() 関数でスキーマを宣言します。z.string() は名前のとおり string 型のスキーマを作成します。

z.infer の型引数に宣言したスキーマを渡すことでスキーマから TypeScript の型を生成できます。usernameSchemastring 型のスキーマなので string 型に推論されます。

④ 定義したスキーマの使い方をテストしています。usernameSchema.parse() 関数を使うことで値を検証できます。ここでは 'john' という string 型の値を渡しているのでバリデーションをパスしてそのまま値が返されます。

⑤ 続いて usernameSchema.parse() 関数に 1 を渡しています。usernameSchema には string 型が期待されていますが、実際には number 型が渡されているため ZodError が例外として投げられます。

また usernameSchema.scheme() で返される値の型は string 型となるので unkown 型を安全に使用できます。

const toUpper = (value: unknown): stirng => {
  try {
    // 
    const username = usernameSchema.parse(value) 
    // const username: string
    return username.toUpperCase()
  } catch (e) {
    if (e instanceof ZodError) {
      console.log(e.message)
    }
    throw e
  }
}

詳細なバリデーション

それぞれのプリミティブ型に合わせて、もう少し詳細なバリデーションを定義できます。例えば z.string().max()z.string().min() は文字列の長さを検証したり、z.number.positive()0 より大きい値であることを検証します。

また { message: '...' } オプションを引数に渡すことで、エラーメッセージをカスタマイズできます。

import { z, ZodError } from 'zod'
 
const usernameSchema = z.string()
  .min(3, { message: 'Username must be at least 3 characters' })
  .max(20, { message: 'Username must be at most 20 characters' })
 
const ageSchema = z.number()
  .nonnegative({ message: 'Age must be non-negative' })
 
if (import.meta.vitest) {
  const { test, expect } = import.meta.vitest
 
  test('invalid username', () => {
    expect(() => usernameSchema.parse('hi')).toThrow('Username must be at least 3 characters')
    expect(() => usernameSchema.parse('a'.repeat(21))).throws('Username must be at most 20 characters')
  })
  test('valid username', () => {
    expect(usernameSchema.parse('hello')).toEqual('hello')
  })
 
  test('invalid age', () => {
    expect(() => ageSchema.parse(-1)).toThrow('Age must be non-negative')
  })
  test('valid age', () => {
    expect(ageSchema.parse(1)).toEqual(1)
  })
}

API のレスポンスの値を検証する

もう少し実践的な例を見ていきましょう。普段 fetch APIaxios など HTTP クライアントを使用するときは API が返すレスポンスを信頼して型を付けることが多いかと思います。ただし、実際にスキーマどおりのレスポンスが返却されるかどうかはランタイムになってみないとわかりません。期待されたスキーマのレスポンスが返ってこないことを考えるとゾッとしますよね・1

API のレスポンスのスキーマを Zod で作成し検証も実行できるようにしてみましょう。z.object でオブジェクト型のスキーマを宣言できます。

import { z, ZodError } from 'zod'
 
const TodoSchema = z.object({
  id: z.number(),
  userId: z.number(),
  title: z.string(),
  completed: z.boolean(),
})
type Todo = z.infer<typeof TodoSchema>
// type Todo = {
//   id: number;
//   userId: number;
//   title: string;
//   completed: boolean;
// }
 
const TodoResponseSchema = z.array(TodoSchema)
type TodoResponse = z.infer<typeof TodoResponseSchema>
// type TodoResponse = {
//   id: number;
//   userId: number;
//   title: string;
//   completed: boolean;
// }[]
 
const fetchTodos = async (): Promise<TodoResponse> => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos')
    if (!response.ok) {
      throw new Error(`${response.status} ${response.statusText}`)
    }
    const json = await response.json()
    // const json: any
 
    const todos = TodoResponseSchema.parse(json)
    // const todos: {
    //   id: number;
    //   userId: number;
    //   title: string;
    //   completed: boolean;
    // }[]
 
    return todos
  } catch (e) {
    if (e instanceof ZodError) {
      // handlel validation error
    }
    throw e
  }
}

fetch のレスポンスにバリデーションを実施したうえで型を付けることができるので、res.json() as TodoResponse のように API を信頼して型を付けるよりはるかに安全に使用できます。

また TypeScript の型を Zod のスキーマから生成できるので、重複したコードを書いたりバリデーションをと型情報の整合性が取れないといったことが発生することがありません。

Footnotes

  1. Zod だけにね


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事