ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

Zod4、信じてるからな

はじめに

こんにちは!株式会社ゼストでエンジニアをしている正原です。 最近より寒くなって鍋が美味しい季節になりましたが、 日本の四季は夏と冬しかないのかな?とよく思うことがあります。

今回は弊社プロダクトを支えていると言っても過言ではない、Zodについての検証です。 Zod v4のプレビュー版が公開されてから約半年ほど経過しました。調べた限り Zod v3 のサポート終了時期に関する情報はないですが、 公式によるとパフォーマンスが大幅に向上したとのことですし、使ってみたい機能もいくつかあるため、 これを機に今後ZESTで使いたい機能と性能に絞って Zod v4 と Zod v3 を比較検証したいと思います。

検証環境について

物理環境

今回の検証は普段僕が使っているMacBook Proで行いました。

MacBook Pro 14-inc 2021
Chip Apple M1 Pro
Memory 32GB

最近バッテリーが劣化してきた感じのする相棒ですが、今回は全力を出してもらいたいと思います。

バージョン

ライブラリ バージョン
node v22.21.1
zod v4.1.13

v4.0.0のリリースノートを読む限り、 Zod は内部的に v3 と v4 を共存させたまま、v4 以降のバージョンにも v3 が含まれているため、この記事を執筆しているタイミングで最新の v4.1.13 を採用しました。

検証手法について

よく「パフォーマンスがN倍になりました」と記載されていたので使ってみたら、「書いてあるほど速くない……?」と思ったことはないでしょうか? もちろん改善すべきシステムのボトルネックが別のところにあったということもあるかもしれませんが、 「このライブラリはどのようにパフォーマンスを計測したのか?」と気になることも多いかと思います。

そんな中、Zod は v3 と v4 のパフォーマンス比較検証が簡単にできるように、 レポジトリにパフォーマンス検証用のスクリプトが含まれており、実際にローカルにクローンして試すことができます。 公式ドキュメントに記載されているパフォーマンス検証も、この結果が記載されているようです。

また、余談ですがマイグレーションガイドも充実しています。 Zod v4 パッケージにはまだ v3 も含まれているため、未検証ではありますが、今後実際に移行する際には一度に全て書き換えるビッグバンリリースではなく、 少しずつ書き換えていけるのではないかと期待しています。

ちなみに、非公式ではありますがマイグレーションスクリプトも用意されているらしく、 移行の際には一度試してみたいなと思っています。

パフォーマンス検証

ここからはクローンしたリポジトリを用いて、機能とパフォーマンスの検証を行いたいと思います。

公式ドキュメントに記載されているパフォーマンス検証

まずは公式に記載されている結果とローカル環境で差分が出るかもしれないので、 公式ドキュメントに記載されていた機能のパフォーマンス検証を行います。 公式リポジトリでは、ベンチマークとしていくつかのライブラリが使えるよう準備されています。

特に何も指定しない場合はデフォルトで mitata が使われるようです。 個人的に JS などのベンチマークに詳しいわけでもないので、そのままデフォルトの設定で行います。

補足になりますが、今回の検証は Zod 公式リポジトリベンチマークスクリプト(packages/bench)を利用するため、 tsx を介して TypeScript ソースコードを直接実行する形での比較となります。 実際のプロダクション環境(ビルド・Minify済みコード)での実行とは、 最適化の挙動が若干異なる可能性がある点をご了承ください。

文字列のパース

$ pnpm bench string

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "string"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.string().parse
------------------------------------------------- -----------------------------
zod3          483 µs/iter     (354 µs … 2'221 µs)    544 µs  1'210 µs  2'211 µs
zod4       33'531 ns/iter  (21'709 ns … 3'137 µs) 25'750 ns    134 µs    483 µs

summary for z.string().parse
  zod4
   14.41x faster than zod3

配列のパース

$ pnpm bench array

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "array"

[ 'RrGFn2MiLE', 'cRCDtJ7E81', 'wq121urXVP' ]
[ 'RrGFn2MiLE', 'cRCDtJ7E81', 'wq121urXVP' ]
cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.array() parsing
------------------------------------------------- -----------------------------
zod3          164 µs/iter     (139 µs … 2'650 µs)    152 µs    371 µs  1'440 µs
zod4       22'506 ns/iter  (17'250 ns … 3'365 µs) 19'709 ns    153 µs    351 µs

summary for z.array() parsing
  zod4
   7.27x faster than zod3

オブジェクトのパース

$ pnpm bench object

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "object"

{
  string: '0.34408880883228843',
  boolean: true,
  number: 0.3180383166514491
}
{
  string: '0.34408880883228843',
  boolean: true,
  number: 0.3180383166514491
}
{
  string: '0.34408880883228843',
  boolean: true,
  number: 0.3180383166514491
}
cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.object().parse
------------------------------------------------- -----------------------------
zod3          269 µs/iter     (236 µs … 1'603 µs)    257 µs    502 µs  1'410 µs
zod4       27'101 ns/iter    (20'041 ns … 497 µs) 31'709 ns    131 µs    202 µs
valibot       175 µs/iter       (161 µs … 526 µs)    172 µs    398 µs    457 µs

summary for z.object().parse
  zod4
   6.46x faster than valibot
   9.93x faster than zod3

総評

文字列・配列・オブジェクトのパースは、公式ドキュメントではそれぞれ14倍・7倍・6.5倍と記載されていましたが、 実際にローカル環境で試してみても概ね変わらない結果となりました。 (失礼かもですが)多少は悪い結果が出るかもしれないという予想に反して、今後の検証に期待が持てそうです。

公式ドキュメント記載されていないが気になる項目のパフォーマンス検証

ドキュメントでは特に注目すべき項目のみ抽出して結果を記載しているのかもしれませんが、 Zod リポジトリにはその他の項目についてもテストが用意されていたため、個人的に気になるものをいくつか試してみたいと思います。

基本的な型のパフォーマンス検証

論理値のパース

$ pnpm bench boolean

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "boolean"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.boolean().parse
------------------------------------------------- -----------------------------
zod3          371 µs/iter     (280 µs … 2'284 µs)    371 µs    737 µs  1'694 µs
zod4       37'894 ns/iter    (22'916 ns … 826 µs) 32'083 ns    127 µs    344 µs

summary for z.boolean().parse
  zod4
   9.78x faster than zod3

数値のパース

$ pnpm bench number

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "number"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.number().parse
------------------------------------------------- -----------------------------
zod3          424 µs/iter     (334 µs … 1'719 µs)    458 µs    811 µs  1'366 µs
zod4       42'583 ns/iter  (27'291 ns … 1'249 µs) 31'125 ns    223 µs    379 µs

summary for z.number().parse
  zod4
   9.95x faster than zod3

日時(Date)のパース

$ pnpm bench datetime

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "datetime"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.string().datetime().parse
------------------------------------------------- -----------------------------
zod3        9'953 µs/iter  (8'447 µs … 13'997 µs) 10'451 µs 13'997 µs 13'997 µs
zod4          834 µs/iter     (638 µs … 5'359 µs)    822 µs  2'687 µs  5'359 µs

summary for z.string().datetime().parse
  zod4
   11.93x faster than zod3

総評

どれも10倍近いパフォーマンスという結果となりました。正直、実際に試してみるまではドキュメントに記載されていない項目についてはあまり変わらない結果になるのではないか?と思っていたのですが、予想を大きく上回る結果でした。詳しくは分かりませんが、そもそもパースにおけるコア部分のパフォーマンスが改善されているのかもしれません。 特にここで試した項目については、Zod を使う上で必ず使っているであろう基本的なものばかりなので、実際に移行した際にどれぐらいパフォーマンスが向上するのか、検証前より気になってきました。

ユニオン関連のパース

ユニオンのパース

$ pnpm bench union

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "union"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.union().parse
------------------------------------------------- -----------------------------
zod3        1'359 ns/iter       (916 ns … 943 µs)  1'084 ns  3'500 ns 51'416 ns
zod4          334 ns/iter     (125 ns … 2'288 µs)    250 ns    834 ns  4'167 ns

summary for z.union().parse
  zod4
   4.07x faster than zod3

判別可能ユニオンのパース

$ pnpm bench discriminated-union

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "discriminated-union"

{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
{
  type: 'c',
  data1: 'YxdVVq4S0o',
  data2: 'YmdKtWKKp3',
  data3: 'd2iPrwQXYd'
}
cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark           time (avg)             (min … max)       p75       p99      p999
------------------------------------------------------ -----------------------------
• z.discriminatedUnion().parse
------------------------------------------------------ -----------------------------
zod 3           43'011 ns/iter  (35'667 ns … 2'425 µs) 39'500 ns    216 µs    354 µs
zod 4 (after)    9'871 ns/iter     (8'750 ns … 587 µs)  9'458 ns 16'125 ns    196 µs
zod 4 (before)  11'594 ns/iter   (9'750 ns … 5'473 µs) 10'625 ns 27'166 ns    253 µs

summary for z.discriminatedUnion().parse
  zod 4 (after)
   1.17x faster than zod 4 (before)
   4.36x faster than zod 3

総評

ユニオンおよび判別可能ユニオンのどちらも約4倍ほどの結果となりました。ユニオンは他の言語にもある EitherResult などを表現するためにも ZEST では多用していますし、tRPC でフロントエンドに型を共有する上でも頻繁に活用しています。 また、判別可能ユニオンにおいては before / after での比較もされています。これは少し調べてみたところ、公式とは別にzod4というフォークされたライブラリとの比較がされていたようでした。

追加検証

さて、これまでは Zod が公式に用意してくれていたパフォーマンス検証をそのまま行ってきましたが、 ここからは用意されたスクリプトを変更して個人的に気になったところや、今後の移行を想定した検証を簡単に行いたいと思います。

ユニオンと判別可能ユニオン

Zod は v3 まで判別可能ユニオンを定義する際、別の判別可能ユニオンのスキーマを含めたり、 同じリテラルを持つスキーマを渡すことができませんでした。

const BaseError = z.object({ status: z.literal("failed"), message: z.string() });

// zod/v4 では可能な定義だが zod/v3 だとエラーになる
// Discriminator property status has duplicate value failed
const MyResult = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.string() }),
  z.discriminatedUnion("code", [
    BaseError.extend({ code: z.literal(400) }),
    BaseError.extend({ code: z.literal(401) }),
    BaseError.extend({ code: z.literal(500) })
  ])
]);

type MyResult = z3.infer<typeof MyResult>;

/*
type MyResult = {
    status: "failed";
    message: string;
    code: 400;
} | {
    status: "failed";
    message: string;
    code: 401;
} | {
    status: "failed";
    message: string;
    code: 500;
} | {
    status: "success";
    data: string;
}
*/

厳密には、 z.discriminatedUnion の場合も型チェックまでは通るのですが、実行時エラーとなります。 また v3 でも一応 z.union で実装は可能だったのですが、v3のドキュメントには、 「判別可能なキーがある場合はより高速な z.discriminatedUnion を用いた方が良い」と記載されていました。 そこで、 z.discriminatedUnion および v3 から v4 にすることで、どれだけパフォーマンスが向上するか検証してみたいと思います。

import { makeData, randomPick, randomString } from "./benchUtil.js";
import { metabench } from "./metabench.js";

import * as z3 from "zod/v3";
import * as z4 from "zod/v4";

const BaseError1 = z4.object({ status: z4.literal("failed"), message: z4.string() });

const MyResult1 = z4.discriminatedUnion("status", [
  z4.object({ status: z4.literal("success"), data: z4.string() }),
  z4.discriminatedUnion("code", [
    BaseError1.extend({ code: z4.literal(400) }),
    BaseError1.extend({ code: z4.literal(401) }),
    BaseError1.extend({ code: z4.literal(500) }),
  ]),
]);

const BaseError2 = z3.object({ status: z3.literal("failed"), message: z3.string() });

const MyResult2 = z3.union([
  z3.object({ status: z3.literal("success"), data: z3.string() }),
  BaseError2.extend({ code: z3.literal(400) }),
  BaseError2.extend({ code: z3.literal(401) }),
  BaseError2.extend({ code: z3.literal(500) }),
]);

/*
 * 実行時エラーになってしまうため、以下はコメントアウト
 * message: Error: Discriminator property status has duplicate value failed
 */
//const BaseError3 = z3.object({ status: z3.literal("failed"), message: z3.string() });
//
//const MyResult3 = z3.discriminatedUnion("status", [
//  z3.object({ status: z3.literal("success"), data: z3.string() }),
//  BaseError3.extend({ code: z3.literal(400) }),
//  BaseError3.extend({ code: z3.literal(401) }),
//  BaseError3.extend({ code: z3.literal(500) }),
//]);
//
//type MyResult3 = z4.infer<typeof MyResult3>;

const newSuccessInput = () => ({
  status: "success",
  data: randomString(10),
});

const newFailedInput = () => ({
  status: "failed",
  message: randomString(10),
  code: randomPick([400, 401, 500]),
});

const DATA = makeData(100, () => {
  const index = randomPick([0, 1]);
  const newInput = [newSuccessInput, newFailedInput];
  return newInput[index]();
});

const bench = metabench("union vs discriminatedUnion", {
  "zod4 with discriminatedUnion"() {
    for (const item of DATA) {
      MyResult1.parse(item);
    }
  },
  "zod3 with union"() {
    for (const item of DATA) {
      MyResult2.parse(item);
    }
  },
});

await bench.run();
$ pnpm bench zest-original

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "zest-original"

cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark                         time (avg)             (min … max)       p75       p99      p999
-------------------------------------------------------------------- -----------------------------
• zod3 with union vs zod4 with discriminatedUnion
-------------------------------------------------------------------- -----------------------------
zod4 with discriminatedUnion  12'994 ns/iter  (10'833 ns … 1'372 µs) 12'084 ns 39'334 ns    253 µs
zod3 with union                  153 µs/iter     (128 µs … 2'185 µs)    143 µs    412 µs  1'066 µs

summary for union vs discriminatedUnion
  zod4 with discriminatedUnion
   11.81x faster than zod3 with union

結果は約12倍ほどとなりました。これまでドキュメントで推奨されている通り、なるべく判別可能ユニオンで実装していましたが、 外部との連携に用いる API など、どうしてもユニオンで表現しなければならなかった実装部分は、 単純に表記を変更するだけでパフォーマンス改善できる可能性がありそうです。

z.union vs z.discrmiantedUnion

これまで v3 のドキュメントにあった「より速い判別可能ユニオンスキーマを用いる方が良い」という記述を盲信して、 なるべく判別可能ユニオンを使うようにしていましたが、これを機会にそもそもどれぐらいパフォーマンスに違いがあるのか試してみたいと思います。

onst z3fields = {
  data1: z3.string(),
  data2: z3.string(),
  data3: z3.string(),
};

const z3Union = z3.union([
  z3.object({
    type: z3.literal("a"),
    ...z3fields,
  }),
  z3.object({
    type: z3.literal("b"),
    ...z3fields,
  }),
  z3.object({
    type: z3.literal("c"),
    ...z3fields,
  }),
  // ...
]);

const z3DiscUnion = z3.discriminatedUnion("type", z3Union._def.options);

const bench = metabench("zod3 union vs discriminatedUnion", {
  "zod 3 (union)"() {
    for (const item of DATA) {
      z3Union.parse(item);
    }
  },
  "zod 3 (discrminatedUnion)"() {
    for (const item of DATA) {
      z3DiscUnion.parse(item);
    }
  },
});

await bench.run();

公式に用意されていた discriminatedUnion のパフォーマンス検証を変更したら可能そうだと思っていたのですが、 ほぼコメントアウトするだけで検証可能でした(至れり尽くせりです)。

$ pnpm bench original

> @ bench /Users/r.shohara/Project/oss/zod
> tsx --conditions @zod/source packages/bench/index.ts "zest-original"

{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
{
  type: 'f',
  data1: 'keAoAOCtwz',
  data2: 'thpe7VgpEr',
  data3: 'qpEkk9ZyKH'
}
cpu: Apple M1 Pro
runtime: node v22.21.1 (arm64-darwin)

benchmark                      time (avg)             (min … max)       p75       p99      p999
----------------------------------------------------------------- -----------------------------
• zod3 union vs discriminatedUnion
----------------------------------------------------------------- -----------------------------
zod 3 (union)                 272 µs/iter     (237 µs … 2'240 µs)    257 µs    533 µs  2'063 µs
zod 3 (discrminatedUnion)  39'627 ns/iter    (35'458 ns … 342 µs) 37'916 ns    165 µs    256 µs

summary for zod3 union vs discriminatedUnion
  zod 3 (discrminatedUnion)
   6.86x faster than zod 3 (union)

結果として、約7倍ほどの差があることが分かりました。 詳細な内部実装までは分かりませんが、恐らくキーによりパースを試みる必要な対象が減ることが主な要因なのではないかと思います。 とはいえ、どれぐらい差があるかあまり分からずに使っていましたが、ここまで差があるとは思っていませんでした。 また恐らくですが、より多くの型の種類を持つユニオンかつ全てのパースに失敗するようなケースの場合、 より一層パフォーマンスに差が出るのではないかと思います。

ちなみに、v4 ではどうなのか?と思って同様の比較をしてみましたが、こちらは4.3倍ほどとなりました。 v4 においても、可能であればなるべく判別可能ユニオンを用いた方が良いのは変わらなさそうです。

ユニオンがまだまだ活躍しそうなところ

今回のアップデートでいくつかユニオンでしか書けなかったところが判別ユニオンで書けるようになりましたが、 依然としてまだユニオンでしか表現できないところは存在するようです。

const a = z4.object({
  id: z4.string(),
});

const b = z4.object({
  id: z4.null().optional(),
});

const unionSchema = z4.union([a, b]);
// ここがエラーになる:Error: Invalid discriminated union option at index "0"
const discriminatedUnionSchema = z4.discriminatedUnion("id", [a, b]);

type UnionSchema = z4.infer<typeof unionSchema>;
type DiscriminatedUnionSchema = z4.infer<typeof discriminatedUnionSchema>;

unionSchema.parse({ id: "test" });
unionSchema.parse({ id: null });

discriminatedUnionSchema.parse({ id: "test" });
discriminatedUnionSchema.parse({ id: null });

こちらは型チェックは通るものの、これまで同様に実行時エラーとなってしまうため、 このような場合は別途キーを定義するか、ユニオンで書くしかなさそうです。

おわりに

いかがでしたでしょうか。簡易的な検証ではありますが、全体的にパフォーマンスが改善されていると言える結果だったと思います。 もしバージョンアップするだけでこの結果通りにパフォーマンス向上できるのであれば、インフラコストの削減、さらには成果として僕達の給料アップにつながるかもしれません。 ZESTではまだ移行がどれだけ大変かまでは検証しきれていない状況ですが、円高になるタイミングも見計らって移行していきたいと思います。