🍚

lizod: 1kb 未満の zod の精神的後継

2023/05/23に公開

作った。 lightweight-zod だから lizodnpm install lizod -S で使える。

tl;dr

  • 各種フロントエンドや Cloudflare Workers で zod のビルドサイズが邪魔になっている
  • メソッドチェーンと便利なユーティリティを全部捨てた zod 風のバリデータを作った
  • zod の 57kb に対して lizod は 1kb 以下

これが動く

// Pick validators for treeshake
import {
  $any,
  $array,
  $boolean,
  $const,
  $enum,
  $intersection,
  $null,
  $number,
  $object,
  $opt,
  $regexp,
  $string,
  $symbol,
  $undefined,
  $union,
  $void,
  type Infer,
  type Validator,
} from "lizod";

const validate = $object({
  name: $string,
  age: $number,
  familyName: $opt($string),
  abc: $enum(["a" as const, "b" as const, "c" as const]),
  nested: $object({
    age: $number,
  }),
  static: $const("static"),
  items: $array($object({
    a: $string,
    b: $boolean,
  })),
  complex: $array($union([
    $object({ a: $string }),
    $object({ b: $number }),
  ])),
  sec: $intersection([$string, $const("x")]),
});

const v: Infer<typeof validate> = {
  name: "aaa",
  age: 1,
  familyName: null,
  abc: "b",
  nested: {
    age: 1,
  },
  static: "static",
  items: [
    {
      a: "",
      b: true,
    },
    {
      a: "",
      b: false,
    },
  ],
  complex: [
    { a: "" },
    { b: 1 },
  ],
  sec: "x",
};

if (validate(v)) {
  const _: string = v.name;
  const __: number = v.age;
  const ___: string | void = v.familyName;
  const ____: "a" | "b" | "c" = v.abc;
  const _____: { age: number } = v.nested;
  const ______: "static" = v.static;
  const _______: Array<{
    a: string;
    b: boolean;
  }> = v.items;
}

なぜ作ったか

最近では Cloudflare Workers の 1MB 上限(有料プランなら5MB)の制限下で remix 等と合わせて zod を動かす需要が高い。remix の約220kb はフレームワークなので仕方ないとして、 zod は 57kb のサイズがある。単機能なバリデータで 57kb はちょっとしんどい。

https://bundlephobia.com/package/zod@3.21.4

bundlephobia は 57kb といっているが、自分の手元でビルドして確認する限りは 43kb ぐらいだった。とはいえ大きい。

フロントエンドのビルドサイズは勿論のこと、Cloudflare Workers のためにも軽量な zod 代替が求められている。

なので、自分が思う zod 代替を作った。

なぜ Zod のサイズは大きいのか

一般的にメソッドチェーンのAPIスタイルを採用したライブラリは、 ESM + 各種バンドラによる不要コード削除の恩恵を受けられない。一つのオブジェクトが名前解決のために別の実装を依存に持つ実装の過程で、結果的にすべての実装を巻き込んでしまう。jQuery などを想像してほしい。

本来なら zod のようなバリデータはメソッドチェーンである必要はなく、関数合成などで表現されてもいいはずだが、それを自然に書けるようにする Pipeline Operator は Stage 2 でまだ議論が白熱していて、すぐに使える見通しはない。

https://github.com/tc39/proposal-pipeline-operator

Zod のコードを読んだ限り、メソッドチェーンによる Tree Shaking への最適化不足と、Error Reporter 及び Error Message を表示するための Locale 定義でそこそこの分量があった。

Zod 自体を Fork することも考えたが、自分のユースケースで本当にほしい部分を自分がほしい形で実装することで、目標を果たせるはずと考えた。

自分が zod に求めている機能

  • TypeScript の型の表現に同じ水準のバリデータのセット
  • 合成されたバリデータへの Infer<T>

なので、こういうAPIセットを最初に想定して作った

import { $object, $array, $union, $number, $string, type Infer } from "lizod";

const validate = $object({
  v: $array($union($string, $number))
});

type MyType = Infer<typeof validate>

const input: any = {
  v: [1, "hello", "world", 9]
}
if (validate(input)) {
  // type narrowing in this scope
  const v: Array<number | string> = input.v
}

zod は既存のバリデータと命令セットを寄せてる関係で、TypeScript の型表現と噛み合わない場所がある。lizod は命令水準が $union$intersect なので、 TypeScript の A | BA & B と同じような型感覚で使える。

いらないもの

  • email() みたいなユーティリティ。代わりに $regexp(expr) を実装しているので、正規表現自体は別に持ってくる。
    • zod が重い理由の一つが、この手の正規表現を内部に大量に巻き込んでいるせい...
  • メソッドチェーン
  • 賢いエラーレポーター

足りないバリデータは自分で作るの精神で、本体では primitive なバリデータしか実装していない。

import type { Validator, ValidatorContext } from "lizod";

// simple validator
const isA: Validator<"A"> = (input: any): input is "A" => input === "A";
const myValidator = $object({
  a: isA,
});

返り値 input is T は type narrowing を起こすので if(myValidator(input)) {/* input: T in this scope */} となる。

賢いエラーリポーターはいらないとしたが、簡単なエラーリポーターは作った。後付で不自然だが、エラーが起きたアクセスパス一覧が取れる。

import { access, $object } from "lizod";

// your validator
const validate = $object({ a: $string });

const input = { a: 1 };

// check with context mutation
const ctx = { errors: [] };
const ret = validate(input, ctx);

// report errors
for (const errorPath of ctx.errors) {
  console.log("error at", errorPath, access(input, errorPath));
}

内部実装の解説

この型パズルを解いた時点で実装はほぼ終わった。

export type Validator<Expect> = (
  input: any,
  ctx?: ValidatorContext,
  path?: (string | symbol | number)[],
) => input is Expect;

export type ValidatorObject<Expect extends {}> = (input: any) => input is {
  [K in keyof Expect]: Expect[K] extends Validator<infer CT> ? CT : never;
};

type ValidatorsToUnion<Vs> = Vs extends Array<Validator<infer T>> ? T
  : never;

export type Infer<T> = T extends ValidatorObject<any> ? {
    [K in keyof T]: Infer<T[K]>;
  }
  : T extends Validator<infer E> ? E
  : never;

あとはこの型にあわせて、 $object, $array, $union のロジックを実装し、プリミティブの $string, $number などを実装した。

最後に

自分が思う zod はこれだが、人によってはユーティリティ集あってこその zod だったり、エラーリポーターないのはけしからんなど、色々と意見があると思う。そういう人にとっても、 lizod のコード量は少ないので fork していじる土台としても便利かもしれない。

少なくとも自分はこのような機能を zod に期待していて、自分が実装すると lizod になった。最近、型パズル力が高まってきたので作れた感じだった。

使ってみた感想や機能提案、PR を待ってます。

https://github.com/mizchi/lizod/issues

Discussion