Hoi hoi! 👋
我是 nyaomaru,一位主要使用 TypeScript 的前端工程師與 OSS 開發者。
當我們在 TypeScript 裡需要執行期驗證時,對話通常會變成這樣 👇
「我該用哪個函式庫?」
或者,乾脆自己寫 type guard?
我維護著 is-kit,所以我一直很在意這條界線。
而最近我開始思考:
我們是不是把其實在解決不同問題的函式庫拿來比較了?
例如,如果我只想要這樣:
if (isUser(input)) {
input.name;
}
我真的需要一套 schema 系統嗎?
但如果我想要:
那麼一個小小的布林型 guard 大概也不夠。
所以我沒有再問 「哪個 validator 最好?」,而是嘗試了另一種比較方式。
我用以下方式實作相同的 User 驗證:
接著我編譯它們、用相同的 fixture 測試、量測獨立瀏覽器 bundle 大小,並跑了一個小型的穩態執行時間基準測試。
最難的部分不是讓七個 validator 都能編譯。
而是讓七個 validator 的意義一致。
就算是「最多 100 個字元的字串」,在執行期也可能代表不同的契約。
所以,讓我們看看每個工具實際上帶來了什麼。😺

先來看共用型別。
type User = {
id: string;
name: string;
age: number;
role: "admin" | "user";
tags: string[];
profile: {
bio: string;
active: boolean;
};
};
執行期規則如下:
id 必須是字串name 必須包含 1–100 個 UTF-16 code unitage 必須是介於 18 到 120 的整數role 必須剛好是 "admin" 或 "user"tags 必須是字串陣列profile.bio 必須是字串profile.active 必須是布林值profile 內都不接受額外 key這次比較的主要輸入範圍是一般的 JSON 解析後資料。
這點很重要,因為 JavaScript 還可能給我們一些奇怪的東西,例如繼承屬性、稀疏陣列、symbol、getter,以及非可列舉 key。
我們稍後再回來談那些。
先從最樸素的選擇開始:手寫 JavaScript guard,再搭配 TypeScript predicate。
不用任何驗證函式庫。
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const hasExactKeys = (
value: Record<string, unknown>,
keys: readonly string[],
): boolean =>
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key));
export const isUser = (value: unknown): value is User => {
if (
!isRecord(value) ||
!hasExactKeys(value, ["id", "name", "age", "role", "tags", "profile"])
) {
return false;
}
if (
!isRecord(value.profile) ||
!hasExactKeys(value.profile, ["bio", "active"])
) {
return false;
}
return (
typeof value.id === "string" &&
typeof value.name === "string" &&
value.name.length >= 1 &&
value.name.length <= 100 &&
typeof value.age === "number" &&
Number.isInteger(value.age) &&
value.age >= 18 &&
value.age <= 120 &&
(value.role === "admin" || value.role === "user") &&
Array.isArray(value.tags) &&
value.tags.every((tag) => typeof tag === "string") &&
typeof value.profile.bio === "string" &&
typeof value.profile.active === "boolean"
);
};
然後:
declare const input: unknown;
if (isUser(input)) {
input.name.toUpperCase();
}
這其實已經非常好了。
對於一個局部邊界,手寫 type guard 有很大的優勢:
不需要學額外的抽象模型。
它就是 JavaScript 的執行期檢查,加上 TypeScript predicate。
但當你的程式碼規模變成這樣時,成本就開始出現了:
isUser;
isProfile;
isRole;
isNonEmptyString;
isAdultAge;
isStringArray;
isApiResponse;
isConfig;
isFeatureFlag;
這時你寫的就不再是「一個 guard」了。
你是在維護你自己的小型 guard 函式庫。
而這就是下一個選項開始有意義的地方。👍
這正是我圍繞 is-kit 所解決的問題。
我不想為每個 narrowing 問題都改用 schema-first 工作流程。
我想要的是小型 guard 保持小型,但能乾淨地組合。
import {
and,
arrayOf,
isBoolean,
isNumber,
isString,
oneOfValues,
predicateToRefine,
safeParse,
struct,
} from "is-kit";
export const isName = and(
isString,
predicateToRefine<string>(
(value) => value.length >= 1 && value.length <= 100,
),
);
export const isAge = and(
isNumber,
predicateToRefine<number>(
(value) => Number.isInteger(value) && value >= 18 && value <= 120,
),
);
export const isProfile = struct(
{
bio: isString,
active: isBoolean,
},
{ exact: true },
);
export const isUser = struct(
{
id: isString,
name: isName,
age: isAge,
role: oneOfValues("admin", "user"),
tags: arrayOf(isString),
profile: isProfile,
},
{ exact: true },
);
export const parseUser = (input: unknown) => safeParse(isUser, input);
重點是:這些片段本身仍然是普通 guard。
const names = values.filter(isName);
if (isAge(value)) {
// value 是 number
}
當你的程式碼本來就是以 predicate 思考時,這很有用。
而這也是我會劃下界線的地方。
safeParse() 只是替 guard 包上一層簡單的成功/失敗結果。
它不會讓 is-kit 變成:
如果那些才是你的真正需求,我寧可直接用 schema 函式庫,也不會硬把 guard 工具箱改造成 schema。
另外一個在驗證過程中浮現的型別細節是:struct() 和 arrayOf() 推斷出來的是 readonly 結構。
所以 runtime 形狀雖然和我們的 User 相符,但 readonly string[] 不能直接指派給可變的 string[]。
這個差異是真實存在的,而且 type tests 會把它明確呈現出來,而不是偷偷用 cast 掩蓋掉。🔧
Runtypes 再往上一層。
它不只是布林 predicate,而是一個 runtime validator 物件,可以用多種方式消費。
import {
Array as RtArray,
Boolean as RtBoolean,
Literal,
Number as RtNumber,
Object as RtObject,
String as RtString,
Union,
} from "runtypes";
const Name = RtString.withConstraint(
(value) =>
(value.length >= 1 && value.length <= 100) || "name length must be 1..100",
);
const Age = RtNumber.withConstraint(
(value) =>
(Number.isInteger(value) && value >= 18 && value <= 120) ||
"age must be an integer in 18..120",
);
export const UserSchema = RtObject({
id: RtString,
name: Name,
age: Age,
role: Union(Literal("admin"), Literal("user")),
tags: RtArray(RtString),
profile: RtObject({
bio: RtString,
active: RtBoolean,
}).exact(),
}).exact();
export const isUser = (input: unknown) => UserSchema.guard(input);
export const checkUser = (input: unknown) => UserSchema.check(input);
export const parseUser = (input: unknown) => UserSchema.parse(input);
export const inspectUser = (input: unknown) => UserSchema.inspect(input);
這和 guard 工具箱的感覺不太一樣。
predicate 主要回答的是:
這個值是不是
User?
Runtype 也能回答這個問題,但同一個物件還能:
guard 做 narrowingcheck 丟出例外parse 解析另外,check() 和 parse() 不能被視為同義詞。
Runtypes 支援 parser 行為,而物件處理在驗證與解析情境中可能不同。
這正是純 predicate 沒有的執行期機制。🏃
Valibot 的比較開始更像「schema」了。
import * as v from "valibot";
export const UserSchema = v.strictObject({
id: v.string(),
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)),
role: v.picklist(["admin", "user"]),
tags: v.array(v.string()),
profile: v.strictObject({
bio: v.string(),
active: v.boolean(),
}),
});
export const isUser = (input: unknown) => v.is(UserSchema, input);
export const parseUser = (input: unknown) => v.safeParse(UserSchema, input);
這讓我們得到:
但 API 仍然是以函式為主,而且是模組化的。
這是 Valibot 設計的重要一部分。
不是所有能力都掛在大型 schema 物件的方法上,而是透過彼此獨立的函式來組合。
這讓 tree shaking 成為預設設計的一部分。
而且和純粹的 schema-only 思維不同,Valibot 也提供 v.is() 作為 type guard。
所以 schema 驗證與一般 TypeScript narrowing 並不是互斥的。
不過有一點要注意:v.is() 是 guard 路徑,它不會像 parse 那樣提供驗證錯誤,也不會套用轉換。🤖
接著是 Zod。
import * as z from "zod";
export const UserSchema = z.strictObject({
id: z.string(),
name: z
.string()
.refine(
(value) => value.length >= 1 && value.length <= 100,
"name must contain 1..100 UTF-16 code units",
),
age: z.int().min(18).max(120),
role: z.enum(["admin", "user"]),
tags: z.array(z.string()),
profile: z.strictObject({
bio: z.string(),
active: z.boolean(),
}),
});
export type User = z.output<typeof UserSchema>;
export const parseUser = (input: unknown) => UserSchema.safeParse(input);
// 這個 schema 沒有轉換,所以它的輸出型別也可以安全地用於輸入。
export const isUser = (input: unknown): input is User => parseUser(input).success;
Zod 沒有內建 is() 方法,但這個 adapter 可以讓 input 縮窄,因為這個 schema 沒有做轉換。若是有轉換的 schema,safeParse().data 才是安全且有型別的值:原始輸入型別與輸出型別可能不同。
Zod 的價值不在於它能驗證 User。
這些函式庫都辦得到。
真正的價值在於 schema 周邊的整個生態:
如果你的 schema 已經開始變成應用基礎架構的一部分,那這個生態系就很重要。💎
不過這個範例有一個重要細節。
我沒有用這個:
z.string().min(1).max(100);
為什麼?
因為我們的契約寫的是:
1–100 個 UTF-16 code unit
Zod 內建的字串長度檢查使用的是 Unicode code point,所以這個範例改用 .refine() 搭配 JavaScript 的 string.length,以保留前述的 UTF-16 契約。下面的共同 emoji fixture 會示範為什麼如此。
太容易直接寫成:
Valibot很小,Zod很大。
但這在 2026 年不是很好的比較方式了。
Zod 也有 Zod Mini,使用更函式化、可 tree-shake 的 API。
這篇文章後面的 bundle 表格量測的是一般版 Zod,不是 Zod Mini。
所以它不應該被解讀成 Valibot 與「最小化設定的 Zod」之間的比較。
Zod 也可以在執行期用 z.compile() 和 new Function 編譯出最佳化的驗證邏輯。
這裡我沒有啟用它。
因此,這裡的區分比「生成式驗證」這個大標籤更有意義:
執行期 schema 編譯
vs
從 TypeScript 型別在 build 時產生程式碼
而這個區別在我們談到 typia 時就很重要。
ArkType 的撰寫模型很不一樣。
import { type } from "arktype";
export const UserSchema = type({
id: "string",
name: "0 < string <= 100",
age: "18 <= number.integer <= 120",
role: "'admin' | 'user'",
tags: "string[]",
profile: {
bio: "string",
active: "boolean",
},
}).onDeepUndeclaredKey("reject");
export const isUser = (input: unknown) => UserSchema.allows(input);
export const parseUser = (input: unknown) => UserSchema(input);
export const ArkErrors = type.errors;
我很喜歡這種比較:
z.int().min(18).max(120);
對上
"18 <= number.integer <= 120";
沒有哪個「比較好」。
它們只是表達執行期契約的不同方式。
如果你的團隊本來就習慣 TypeScript 風格的表達式,ArkType 會非常直觀。
allows() 也可以當作 type guard。
而直接呼叫 schema 則會回傳驗證後的輸出或錯誤集合。
還有一個有趣的點:當環境允許 new Function 時,ArkType 可以在執行期預先編譯出最佳化的 traversal 邏輯。
所以 ArkType 本身就已經在做 specialization。🚢
但這個架構仍然和 typia 不同。
ArkType 是從 runtime 的 type representation 出發。
typia 則是在 build 階段從 TypeScript 型別出發。
typia 在這次比較中是異類。
import typia, { type tags } from "typia";
type Utf16Name = tags.TagBase<{
kind: "utf16Name";
target: "string";
value: undefined;
validate: "$input.length >= 1 && $input.length <= 100";
}>;
export interface User {
id: string;
name: string & Utf16Name;
age: number & tags.Type<"uint32"> & tags.Minimum<18> & tags.Maximum<120>;
role: "admin" | "user";
tags: string[];
profile: {
bio: string;
active: boolean;
};
}
export const isUser = typia.createEquals<User>();
export const validateUser = typia.createValidateEquals<User>();
這不是一個 runtime schema 物件。
來源真相是 TypeScript 型別,再加上 typia 的 tags。
接著 typia 的 build transform 會把:
typia.createEquals<User>();
轉換成專門化的驗證程式碼。
這代表不會有一個通用的 runtime schema interpreter 在那裡走訪 User schema 物件。
但這也有取捨:
你需要 build 整合。
目前官方設定使用 TypeScript 7 與 ttsc / typia 的 transform 整合。
單靠原生 tsc 不會執行這個 transform。
在我的驗證工作區中,第一次建立 typia 時還必須編譯一個 Go 原始碼外掛,整體花了幾分鐘。
這個設定成本不算在 runtime benchmark 裡,但絕對是採用決策的一部分。
另外,typia 內建的 MinLength / MaxLength 不符合我們的 UTF-16 契約。
所以我為 name 使用了自訂 tag。
同樣看起來像是相同的驗證規則,但語意其實不同。🚀
這是第一個讓我停下來的測試。
"😀".repeat(51).length;
// 102
JavaScript 的 string.length 計算的是 UTF-16 code unit。
所以依照我們的契約:
最大值 = 100 個 UTF-16 code unit
這個應該失敗。
而且它確實在以下實作中失敗了:
is-kitRuntypesValibotArkType但原本的 Zod 和 typia 範例卻接受了它。
為什麼?
因為它們內建的長度限制是以 Unicode code point 計算。
對於這個字串:
51 個 emoji
= 51 個 code point
= 102 個 UTF-16 code unit
兩種行為都沒有錯。
它們只是不同的契約。
而且它們也不一定等於「人眼看到的字數」,因為 grapheme cluster 會讓 Unicode 更有趣。😹
所以我把 Zod 改成 .refine(),把 typia 改成自訂 tag。
這大概是整個比較裡最有價值的一課 👇
看起來相同的 schema,不代表 runtime 契約一定相同。
到了這裡,這些工具已經不再像七個可互換的函式庫。
它們是不同的 runtime 模型。
| 方式 | 真實來源 | 最自然的成功 API | 結構化失敗資訊 | 轉換模型 | 編譯 / build 需求 |
|---|---|---|---|---|---|
| 手寫 guard | 型別 + 手寫檢查 | Predicate | 手動處理 | 手動處理 | 無 |
| is-kit | 組合式 guards | Predicate / safeParse |
基本的 valid/invalid 結果 | 以 guard 為中心 | 無 |
| Runtypes | Runtype 物件 | guard / check / parse |
有 | Parsers | 無 |
| Valibot | Schema 函式 | is / safeParse |
有 | Schema pipelines | 無 |
| Zod | Schema 物件 | parse / safeParse |
有 | Transforms / codecs | 無 build transform;可選執行期 z.compile() |
| ArkType | 執行期型別表達式 | allows / 直接呼叫 |
有 | Morphs | 無 build transform;可在執行期預編譯(若可用) |
| typia | TypeScript 型別與 tags | 生成的 predicates / 驗證結果 | 搭配驗證 API | build 期間生成函式 | 需要 build transform |
這也是為什麼我不再喜歡把它們想成單純的升級階梯。
schema 函式庫不代表「更好的 type guard」。
生成式 validator 也不代表「更好的 schema」。
它們是在不同層級解決重疊的問題。
完整的 harness、lockfile、fixtures、探針與產生出的結果檔都放在 typescript-validator-comparison。文章連結的是那個不可變的 commit,而不是會變動的分支。
若要重現當時的快照:
git clone https://github.com/nyaomaru/typescript-validator-comparison.git
cd typescript-validator-comparison
git checkout --detach b26350d01fd18cb77d55a95e2f3b112657f0d305
corepack pnpm install --frozen-lockfile
corepack pnpm verify
pnpm verify 會執行 lint、strict type checking、fixtures、bundle 量測、benchmarks、純 JavaScript 探針,以及報告生成。下面的結果表就是那次執行保存下來的快照;在別台機器上重新執行,自然會得到不同的時間數字。
我鎖定版本並用相同的 fixture matrix 測試所有七個實作。
環境
Date: 2026-10-23
Node: 24.22.1
TypeScript: 7.0.2
pnpm: 11.22.0
ttsc: 0.31.3
tsbuild: 0.28.2
套件
| 套件 | 版本 |
|---|---|
| is-kit | 1.15.3 |
| runtypes | 7.0.5 |
| valibot | 1.5.0 |
| zod | 4.6.5 |
| arktype | 2.2.8 |
| typia | 15.1.0 |
共用 matrix 包含 35 個 JSON fixture。
其中包含:
七個實作都符合預期結果。
35 fixtures
× 7 validators
= 245 boolean checks
這 245 次全部通過。
這個 harness 也會檢查 input mutation、更豐富的 API、嚴格的 TypeScript 編譯、lint、build、narrowing,以及 bundle 輸出行為。
所以這些實作對於本文中的 JSON 形狀契約是一致的——但不一定對所有 JavaScript 值都一致。
我也測試了正常 JSON 形狀輸入範圍以外的值。表格中的四個探針意思如下:
const sparseTags = { ...valid, tags: new Array(2) };
const inheritedFields = Object.create(valid);
const extraSymbol = { ...valid, [Symbol("extra")]: true };
const extraUndefined = { ...valid, extra: undefined };
倉庫另外還有九個探針,包括非可列舉屬性與會拋錯的 getter。以下是這四個的結果:
| 探針 | Native | is-kit | Runtypes | Valibot | Zod | ArkType | typia |
|---|---|---|---|---|---|---|---|
稀疏 tags 陣列 |
接受 | 拒絕 | 接受 | 拒絕 | 拒絕 | 拒絕 | 接受 |
| 所有欄位皆為繼承屬性 | 拒絕 | 拒絕 | 拒絕 | 接受 | 接受 | 接受 | 接受 |
| 額外的 symbol 屬性 | 接受 | 接受 | 拒絕 | 接受 | 接受 | 拒絕 | 接受 |
額外 key,值為 undefined |
拒絕 | 拒絕 | 拒絕 | 拒絕 | 拒絕 | 拒絕 | 接受 |
我測試的那個額外非可列舉屬性,七個都接受。
遇到會拋錯的 getter 時,七個都會丟出例外。
這不代表某個函式庫「正確」,而其他都壞掉。
它代表的是:
「exact object validation」必須先定義範圍。
例如,我的原生 guard 使用的是:
Object.keys(value);
它只會看到可列舉的字串 key。
而這段:
value.tags.every(...)
會跳過稀疏陣列中的洞。
對 JSON 資料來說這完全合理。
但它並不是一條適用於 JavaScript 所有奇怪物件的通用政策。
這也是我認為 validator 比較應該先說明預期輸入範圍的原因。
接著是有趣的表格。😺
每個瀏覽器目標的 entry 都只輸出 validate。
它們都用相同的 esbuild 設定打包:
然後我量測原始大小、gzip 與 Brotli 輸出。
| Validator | Raw bytes | gzip bytes | Brotli bytes |
|---|---|---|---|
| 手寫 guard | 588 | 337 | 295 |
| is-kit | 3,421 | 1,530 | 1,423 |
| runtypes | 16,667 | 5,188 | 4,657 |
| valibot | 5,722 | 1,853 | 1,675 |
| zod | 89,888 | 26,212 | 23,331 |
| arktype | 155,549 | 47,603 | 41,956 |
| typia | 772 | 403 | 339 |
幾個重要的注意事項:
如果你的 app 已經在用 Zod,再多一個 schema 的邊際成本不會是 26 KB gzip。
這張表量的是獨立 entry bundle。
不是 Zod Mini。
所以這張表不能回答:
ValibotvsZodMini?
typia 在這個測試中的最終 guard bundle 只有 772 bytes raw。
但它仍然包含生成出來的程式碼和一個 uint32 helper。
「生成式 validator」不代表「免費 validator」。
不過,架構上的差異在這裡非常明顯。
我原本差點不放 runtime 數字,因為 validator benchmark 很容易誤導人。
但既然這個工作區已經有相同契約和 fixtures,我還是跑了一個小型穩態 benchmark。
三種情境:
idprofile.active每個函式庫都在獨立的 Node process 中執行。
每種情境使用:
schema 和 validator 都在量測迴圈外建立。
結果是每次呼叫的中位數奈秒數,括號內為最小值到最大值。
| Validator | Valid | Wrong id |
Wrong profile.active |
|---|---|---|---|
| 手寫 guard | 79.9 (77.3–114.1) | 73.6 (72.1–84.9) | 80.8 (79.0–96.3) |
| is-kit | 232.0 (225.7–257.5) | 34.5 (33.1–38.9) | 210.3 (203.6–218.1) |
| runtypes | 5804.0 (5425.5–6298.0) | 9221.8 (8443.2–11063.2) | 10101.2 (9287.6–10503.5) |
| valibot | 951.2 (425.2–1782.7) | 200.2 (96.1–422.3) | 1112.1 (648.4–1538.1) |
| zod | 612.6 (351.3–1203.5) | 1491.6 (862.9–2824.6) | 2208.5 (1592.4–4757.4) |
| arktype | 291.5 (278.1–614.4) | 9.6 (6.6–17.7) | 12.0 (11.3–37.6) |
| typia | 21.9 (20.5–46.1) | 2.0 (1.9–6.4) | 11.5 (10.8–17.2) |
請不要把這解讀成:
typia 是最快的 validator。
這是微基準測試。
相同的小物件形狀會被反覆驗證,V8 會非常積極地最佳化。
我不會把 2.0 ns 當成真實應用中「驗證成本」的可移植數字。
成功 API 也不同:
is-kit 使用 predicateRuntypes 使用 .guard()Valibot 使用 is()Zod 使用 safeParse(...).successArkType 使用 allows()typia 使用生成的 equals guard這些操作並不全都做完全相同的事。
這個 benchmark 真正告訴我們的是:架構和失敗路徑都很重要。
例如:
id 之前,先付出了 exact-key 檢查的成本這些資訊是有用的。
但不要把它當成通用速度排名。
做完這些之後,我的決策樹其實很簡單。
用手寫 guard。
if (isUser(input)) {
// input 是 User
}
當問題很小時,沒有依賴本身就是一種優勢。
這時我會用 is-kit。🔧
values.filter(isName);
if (isAge(value)) {
// ...
}
問題不是「我需要 schema」。
問題是「我一直在重寫相同的 predicates 和組合輔助函式」。
如果你喜歡有一個 runtime 物件,同時可以做:🏃
guard
check
parse
inspect
assert
那 Runtypes 很值得注意。
Valibot。🤖
特別是當 client bundle 大小與 tree shaking 是設計重點時。
Zod。💎
在很多應用裡,這大概就是它最強的存在理由。
如果你在意 bundle size,請另外評估 Zod Mini。
ArkType。🚢
"18 <= number.integer <= 120";
這和鏈式 schema 方法是完全不同的撰寫體驗。
typia。
但我會把 build 整合視為決策的一部分,而不是註腳。
這些工具彼此重疊,但它們位於不同層級。
難的不是讓七個 validator 都能編譯,而是讓七個 validator 的意義一致。
所以在比較套件大小或 benchmark 截圖之前,先問問看你的邊界到底需要回傳什麼,以及值不值得帶上哪些 runtime 機制。
用最小、但仍能充分描述你的邊界的模型。
那麼,你會用哪一個呢?
感謝閱讀!🤲
原文出處:https://dev.to/nyaomaru/i-compared-6-typescript-validation-libraries-heres-what-surprised-me-325g