Closed31

[キャッチアップ] zod

shingo.sasakishingo.sasaki

Introduction

  • Zod は TS ファーストに宣言的にスキーマを定義し、バリデーションを実行できるライブラリ
  • Zod の目的は型定義の重複を減らすことで、一度バリデーターを定義するだけで複雑な型定義も自動で行うことができる
shingo.sasakishingo.sasaki

Instalattion

TypeScript は 4.1 からサポートされ、strict モードで TypeScript を使用することが必須になる。

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

インストールコマンド

npm install zod
shingo.sasakishingo.sasaki

Basic Usage

zod モジュールを用いてスキーマを定義し、それを満たしているかのバリデーションが可能

import { z } from "zod";

// User スキーマの作成
const User = z.object({
  username: z.string(),
});

// スキーマに基づくバリデーション(成功例 {username: "John"})
const user1 = User.parse({
  username: "John",
});

// スキーマに基づくバリデーション(失敗例 throws ZodError)
const user2 = User.parse({
  username: 123,
});

スキーマから型を抜き出せる

// スキーマから型を抜き出す
type User = z.infer<typeof User> // { username: string }
shingo.sasakishingo.sasaki

Primitives

いつもの面々だけどランタイムでバリデートできるので、 bigintdate みたいなのものある。

z.string();
z.number();
z.bigint();
z.boolean();
z.date();

特殊な型もプリミティブとして用意されてる

z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
shingo.sasakishingo.sasaki

Literals

TSファーストなのでリテラル型に対応するスキーマもある

z.literal('foo')
z.literal(12)
z.literal(true)

リテラルは値が確定してるので value を取り出せる

z.literal('Hello').value // 'Hello'
shingo.sasakishingo.sasaki

String

zod はランタイムバリデータなので、型だけでなく値レベルでの制約を定義できる

z.string().max(5) // 最大5文字
z.string().min(5) // 最小5文字
z.string().length(5) // 5文字のみ
z.string().email() // メールアドレス
z.string().url() // URL
z.string().uuid() // UUID
z.string().regex(/^[a-z]+$/) // 正規表現
z.string().startsWith('foo') // 'foo'で始まる
z.string().endsWith('foo') // 'foo'で終わる

各スキーマでのバリデーションエラー時のメッセージはカスタムできる

z.string().max(5, { message: '最大5文字です' })
z.string({
  required_error: '必須です',
  invalid_type_error: '文字列ではありません',
}
shingo.sasakishingo.sasaki

Number

同様にランタイムバリデータを定義できる

z.number().gt(5) // 5より大きい
z.number().gte(5) // 5以上
z.number().lt(5) // 5より小さい
z.number().lte(5) // 5以下
z.number().positive() // 正の数
z.number().negative() // 負の数
z.number().nonpositive() // 0以下
z.number().nonnegative() // 0以上
z.number().int() // 整数
z.number().multipleOf(5) // 5の倍数
shingo.sasakishingo.sasaki

NaNs

NaN であるというスキーマ。あんまり使い所はなさそうだけど。

z.nan({
  invalid_type_error: "NaNではありません",
});
shingo.sasakishingo.sasaki

Dates

日付のバリデーションも可能

z.date().min(new Date("2020-01-01")); // 2020-01-01以降
z.date().max(new Date("2020-01-01")); // 2020-01-01以前
shingo.sasakishingo.sasaki

Zod enums

いずれかの値を取る制約で、TSではユニオンリテラルになる

const values = z.enum(["foo", "bar", "baz"]); // foo, bar, bazのいずれか
type Enum = z.infer<typeof values>; // 'foo' | 'bar' | 'baz'

変数を介する場合は、型推論が効くように as const をつける

const VALUES = ["Salmon", "Tuna", "Trout"] as const;
const FishEnum = z.enum(VALUES);

値を取り出すこともできる

const e = z.enum(["foo", "bar", "baz"]);
console.log(e.enum); // { foo: 'foo', bar: 'bar', baz: 'baz' }
console.log(e.options); // ['foo', 'bar', 'baz']

TypeScript の enum を使うこともまぁできるが推奨はされてない。

enum Fruits {
  Apple,
  Banana,
}

const FruitEnum = z.nativeEnum(Fruits);
type FruitEnum = z.infer<typeof FruitEnum>; // Fruits

FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Banana); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse(1); // passes
FruitEnum.parse(3); // fails
shingo.sasakishingo.sasaki

Optionals

スキーマをオプショナル(undefined とのユニオン) にする

const schema = z.object({
  name: z.optional(z.string()), // ラップするパターン
  age: z.number().optional(), // チェインするパターン
});

type Type = z.infer<typeof schema>; // { name?: string | undefined; age?: number | undefined; }

オプショナル化したスキーマをアンラップすることもできる。

const requiredSchema = z.object({
  name: z.optional(z.string()).unwrap(), // ラップするパターン
  age: z.number().optional().unwrap(), // チェインするパターン
});

type Type = z.infer<typeof requiredSchema>; // { name: string; age: number; }
shingo.sasakishingo.sasaki

Nullables

同様に nullable (Null とのユニオン)

z.nullable(z.string());
z.string().nullable();

unwrap は optional と同じ

z.string().nullable().unwrap();
shingo.sasakishingo.sasaki

Objects

これまでもちょいちょい出てきたけど、キーバリュー形式のデータ構造のスキーマを定義するのに使う。

const Dog = z.object({
  name: z.string(),
  age: z.number(),
});

便利メソッド

object は特に多機能でいろんなメソッドが生えてるのでサクッと流す。


Dog.shape.name; // 各プロパティの型を参照できる
Dog.keyof(); // ['name', 'age']
Dog.extend({ bark: z.string() }); // スキーマを拡張する
Dog.merge(z.object({ bark: z.string() })); // スキーマをマージする
Dog.pick({ name: true }); // スキーマから一部を抜き出す
Dog.omit({ age: true }); // スキーマから一部を省く
Dog.partial() // 各プロパティをオプショナルにする
Dog.deepPartial() // 各プロパティを再帰的にオプショナルにする
Dog.required() // 各プロパティを必須にする

.passthrough

Zod オブジェクトはデフォルトではスキーマで定義されていないプロパティはバリデーション時に除外される。

const personSchema = z.object({
  name: z.string(),
});

const person = personSchema.parse({
  name: "lorem",
  age: 43,
});

console.log(person); // { name: 'lorem' }

.passthrough() を挟むことで、これを除外せずに無視してくれるようになる。

const person = personSchema.passthrough().parse({
  name: "lorem",
  age: 43,
});

console.log(person); // { name: 'lorem', age: 43 }

.strict

逆に .strict() を挟むことで、余計なフィールドがあった場合に例外を投げるようになる。

const personSchema = z.object({
  name: z.string(),
});

const person = personSchema.strict().parse({
  name: "lorem",
  age: 43,
});

console.log(person); // => throws ZodError

.strip()

passthroughstrict の制約をリセットする

const person = personSchema.passthrough().strict().strip().parse({
  name: "lorem",
  age: 43,
});

console.log(person); // => { name: 'lorem' }

.catchall

未定義のフィールドに対してまとめて実行するバリデーションを定義可能。

const personSchema = z.object({
  name: z.string(),
}).catchall(z.number());

この場合、 name 以外のプロパティも number なら許可される。

shingo.sasakishingo.sasaki

Array

言わずもがなの配列型。これもラップ形式とチェイン形式どちらもある。

z.array(z.string()) // string[]
z.string().array() // string[]

チェインの場合は順番に注意

z.string().optional().array() // (string | undefined)[]
z.string().array().optional() // (string[] | undefined)

便利メソッド

z.array(z.string()).nonempty() // 空配列は許可しない
z.array(z.string()).min(3) // 少なくとも3要素以上
z.array(z.string()).max(3) // 最大3要素まで
z.array(z.string()).length(3) // 3要素のみ
shingo.sasakishingo.sasaki

Unions

ユニオン型

z.union([z.boolean(), z.string()]); // boolean | string
z.boolean().or(z.string()); // boolean | string
shingo.sasakishingo.sasaki

Discriminated unions

ユニオン型と挙動はだいたい一緒だけど、多くのプロパティが共通で一部だけが異なるスキーマのユニオンの場合、どのフィールドを使って判定をするかを明示することができる。エラーメッセージがわかりやすくなるメリットがある。

const item = z
  .discriminatedUnion("type", [
    z.object({ type: z.literal("a"), a: z.string() }),
    z.object({ type: z.literal("b"), b: z.string() }),
  ])
  .parse({ type: "a", a: "abc" });
shingo.sasakishingo.sasaki

Records

TS の Record 型に対応

z.record(z.string(), z.number()); // Record<string, number>

キーに対して制約をかけることも可能

z.record(z.string().length(10), z.number()); // キーは10文字
shingo.sasakishingo.sasaki

Intersections

インターセクション型

const Person = z.object({
  name: z.string()
})
const Employee = z.object({
  role: z.string()
})
const EmployeePerson = z.intersection(Person, Employee); // { name: string } & { role: string }

あるいは and も使用可

Person.and(Employee) 

ただし大抵は .merge を使用するほうが望ましい。 intersection を使うと、 ZodIntersection という新しい型のオブジェクトが返ってくるため、通常の ZodObject にある pickomit などが使えなくなり応用が効かなくなるため。

shingo.sasakishingo.sasaki

Recursive types

zod ではその仕組み上、自身を参照する再帰的なスキーマを定義する場合は冗長な定義が必要になる。

以下のように、自身の評価を後回しにする lazy を使用することと、型推論出来ないため明示的に interface を定義することが必要になる。

interface Category {
  name: string;
  subcategories: Category[];
}

const Category: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(Category),
  })
);
shingo.sasakishingo.sasaki

Promises

Promise 型のスキーマ

z.promise(z.number()); // Promise<number>

ただし parse 時の挙動はやや特殊

  • ランタイムチェックでは Promise が何を返すのかは不明なので、Promise インスタンスであることをチェックする
  • parse が async function となり、Promise が解決した時点で再度返り値を判定する

例えば以下では、 parse を呼び出した時点では Promise インスタンスを渡しているのでバリデーションに成功するが、Promise が解決した3秒後にはエラーが確定する。

async function sleep(ms: number, value: any) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

const numberPromise = z.promise(z.number()); // Promise<number>
numberPromise.parse(sleep(3000, "test")).catch((e) => {
  console.error(e); // Expected number, received string
});
shingo.sasakishingo.sasaki

Instanceof

あるクラスのインスタンスであることを検証するユーティリティ

class Test {
  constructor(private name: string) {
    this.name = name;
  }
}

z.instanceof(Test).parse(new Test("test"));
shingo.sasakishingo.sasaki

Function schemas

関数についてもバリデーションが可能

z.function() // => () => unknown
z.function().args(z.string(), z.number()).returns(z.boolean()) // => (arg0: string, arg1: number) => boolean

なんとパラメータのバリデーションと実装を同時にできるし、実装がバリデータを満たしてない場合はランタイムエラーになる。


const f = z
  .function()
  .args(z.string(), z.number())
  .returns(z.boolean())
  .implement((str, num) => {
    return true;
  });

console.log(f("test", 1));
shingo.sasakishingo.sasaki

Preprocess

バリデーション前に入力データの変換を行う。 (通常は Zod はバリデーション後に変換を行うのが思想ではあるが)

const castToString = z.preprocess((val) => String(val), z.string());
console.log(castToString.parse(1)); // "1"

この場合は valunknown になるため、どんな値でも渡せてしまうので注意

shingo.sasakishingo.sasaki

Schema methods

Zod スキーマに対して使用できるメソッドで、これまで登場したものはサクッと

// スキーマを元に値をバリデーションし、正常時はそのまま値を返す
schema.parse({ name: "John", age: 30 });

// バリデーション後に非同期のデータ変換がある場合は、parseAsyncを使う
schema.parseAsync({ name: "John", age: 30 });

// バリデーション失敗時も例外を投げず、エラー情報を返す
schema.safeParse({ name: "John", age: "30" });

// safeParse + parseAsync
schema.safeParseAsync({ name: "John", age: "30" });

// プロパティをオプショナル化したスキーマを返す
schema.optional();

// プロパティを Nullable にしたスキーマを返す
schema.nullable();

// プロパティを Nullish (nullable + optional) にしたスキーマを返す
schema.nullish();

// スキーマ全体を配列化したスキーマを返す
schema.array();

// スキーマ全体をPromise化したスキーマを返す
schema.promise();

// ユニオンスキーマを生成する
schema.or(z.string());

// インターセクションスキーマを生成する
schema.and(z.object({ foo: z.string() }));

.refine

カスタムバリデーションルールを定義できる。

z.string().refine((s) => s.length > 3, { message: "too short" });

refine は非同期関数を使用することもできるので、DBに問い合わせるといったランタイムバリデーションも可能。

const userId = z.string().refine(async (id) => {
  // verify that ID exists in database
  return true;
});

.superRefine

より高度なことができる refine で、refine も実態はこれのシンタックスシュガー。
ほんとに色々出来るみたいなのでサンプルコードだけ引用して省略

const Strings = z.array(z.string()).superRefine((val, ctx) => {
  if (val.length > 3) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_big,
      maximum: 3,
      type: "array",
      inclusive: true,
      message: "Too many items 😡",
    });
  }

  if (val.length !== new Set(val).size) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `No duplicates allowed.`,
    });
  }
});

transform

バリデーション後、または途中でデータ変換を行う

z.string().transform((val) => val.length);

default

プロパティをオプショナルとし、省略された場合のデフォルト値を設定できる。

const schema = z.number().default(0);
console.log(schema.parse(undefined)); // 0

プリミティブのデフォルト値に関数を指定した場合は都度実行される

const schema = z.number().default(Math.random);
console.log(schema.parse(undefined)); // 毎回
console.log(schema.parse(undefined)); // 違う
console.log(schema.parse(undefined)); // 値になる

.brand

TypeScript は通常、構造的部分型を採用しているため、構造が合致していれば同じ型とみなすため Zod もそれに従うが、 .brand を使用することで同じ Zod スキーマから生成した値でなければ違う型とみなすことができる。

// { name: string } の型に Cat という名前をつける
const Cat = z.object({ name: z.string() }).brand<"Cat">();

// Cat スキーマの型を用意しておく
type Cat = z.infer<typeof Cat>;

// Cat 型のみ受け取る関数
function sayHello(cat: Cat) {
  console.log(cat.name);
}

// simba は Cat 型のため、sayHello に渡せる
const simba = Cat.parse({ name: "Simba" });
sayHello(simba);

// 以下は構造的部分型に従えば Cat 型を満たしてはいるが、渡すことができない
sayHello({ name: "Simba" });
shingo.sasakishingo.sasaki

Guides and concepts

簡単な活用手法についてのまとめ

Type inference

infer を使えばスキーマから型を抜き出せる

const A = z.string();
type A = z.infer<typeof A>; // string

ただし、 transform をした場合、バリデーションの中でデータの変換が行われるため、入力と出力で型が異なる場合がある。

// 入力は string だけど出力は number になる
z.string().transform((val) => val.length);

この場合は input output を使用して型推論する

type input = z.input<typeof stringToNumber>; // string
type output = z.output<typeof stringToNumber>; // number

Writing generic functions

任意の Zod スキーマを受け取る関数を書く場合

function makeSchemaOptional<T extends z.ZodTypeAny>(schema: T) {
  return schema.optional();
}

string 型の Zod スキーマに絞る場合

function makeSchemaOptional<T extends z.ZodType<string>>(schema: T) {
  return schema.optional();
}

Error handling

Zod はバリデーション失敗時に ZodError 型のエラーを返す。
詳細仕様はここに
https://zod.dev/ERROR_HANDLING

Error formatting

エラーをシンプルなオブジェクトに変換できる

const data = z
  .object({
    name: z.string(),
  })
  .safeParse({ name: 12 });

if (!data.success) {
  const formatted = data.error.format();
  /* {
    name: { _errors: [ 'Expected string, received number' ] }
  } */

  formatted.name?._errors;
  // => ["Expected string, received number"]
}
shingo.sasakishingo.sasaki

Comparison

他のバリデーションライブラリとの比較

Joi

バリデーションライブラリだけど型推論機能は無い

Yup

zod と近いけど、zod には promise / function / union / intersection とより細かなスキーマを定義できる

io-ts

zod の API に影響を与えたライブラリ。

関数型プログラミングにフォーカスしており、fp-ts にも依存する上、 promise / function などが無い

Runtypes

readonly のような zod にない機能も有しているが、 pick / omit / extend などに欠ける

Ow

関数のパラメータの検証に特化しており、TypeScript の型システムを超えた型を表現できる。

このスクラップは2022/10/22にクローズされました