zod

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

31posts · 30d
Nov 31 post
  1. Hey @colinhacks, could you help me get MongoZod on the Zod ecosystem page?🙏 zod#6723

    5 views
  2. Zod: 3m 10s on one Blacksmith job, 72s on boxd. Tests split on both: 2m 19s vs 72s. The checks run in sequence and take as long as the tests, so more shards change nothing. colinhacks/zod t.co

    4 views
  3. Am I the only one who gets sad and angry when stumbling over discussions like in zod#4939 ? If I think AI is helpful, I can ask for myself - no value provided.

    1 like
  4. Zenn · · @nyaomaru

    TypeScript の Validation Library 6つを比較してみた!意外だったのは性能じゃなかった話 🤔 みなさん、こんにちは! エビ 🦐 と Type Guard のことばっかり考えているフロントエンドエンジニアの @nyaomaru です!😸 TypeScript で Runtime Validation が必要になると、だいたいこんな話になるよな。 「結局、どの Library を使えばええん?」 Zod? Valibot? ArkType? typia? それとも、自分で Type Guard を書くだけで十分なんちゃう? 僕は is-kit を開発してるので、この境界についてよく考えてる。 そこで最近、こんな疑問が出てきた。 そもそも、同じ問題を解決している Library 同士を比較できてるんやろか? 例えば、欲しいのがこれだけなら、 if (isUser(input)) { input.name; } ほんまに Schema System まで必要なん? でも逆に、 構造化された Validation Error 値の Transformation Parsing Schema Metadata Framework Integration が必要なら、小さな Boolean Guard だけでは足りへんこともある。 そこで今回は「どれが一番ええ Validator なん?」ではなく、ちょっと違う観点で比較してみた。 同じ User の Validation を、以下の7つの方法で実装したで。 手書きの JavaScript Guard + TypeScript の Type Predicate is-kit Runtypes Valibot Zod ArkType typia 6つの OSS と、Library を使わない手書き実装やな。 それぞれを Compile して、同じ Fixture で Test して、Browser Bundle Size を測って、簡単な Steady-state Runtime Benchmark も回した。 で、一番難しかったのは、7つを Compile することじゃなかった。 7つの Validator に、同じ意味の Validation をさせることやった。 例えば「100文字以下の String」という条件ですら、Runtime では違う意味になり得る。 ほんなら、それぞれ何ができて、何を引き換えにしているのか見てこか〜!😺 github.com/nyaomaru 🧪 今回 Validate する User まずは共通の Type から。 type User = { id: string; name: string; age: number; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; }; Runtime で検証する Rule は以下のとおり。 id は String name は UTF-16 Code Unit で1〜100 age は18〜120の整数 role は "admin" または "user" のみ tags は String の Array profile.bio は String profile.active は Boolean Root と profile の余分な Key は拒否 値の Coercion(型変換)はしない 今回の主な Input は、JSON を Decode した普通の Data を想定してる。 ここは重要。 JavaScript には Inherited Property、Sparse Array、Symbol、Getter、Non-enumerable Property みたいな変わった値も作れるからな。 この話は後で戻ってくるで。😹 1. ✍️ 手書き Guard — Guard だけで十分なこともある まずは一番地味な方法。TypeScript の Type Predicate を付けた、手書きの JavaScript Guard や。 Validation Library は使わない。 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(); } 一箇所だけ Narrowing したいなら、手書き Guard で十分やと思う。 Dependency も新しい抽象化もいらへん。 でも isUser、isProfile、isRole みたいな Guard がどんどん増えてきたら……。 自分だけの小さな Guard Library を保守し始めてる わけやな。😹 そこで次の選択肢が出てくる。👍 2. 🔧 is-kit — 小さな Guard を再利用したくなったら まさにここが、僕が is-kit を作った理由や。 Narrowing のたびに Schema-first の Workflow を使いたかったわけちゃう。 小さな Guard は小さいまま、きれいに Compose できるようにしたかった。 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); 僕にとって大事なのは、struct() で Object を Validate できること自体じゃない。 組み合わせた部品が、普通の Guard としてそのまま使えること や。 const names = values.filter(isName); if (isAge(value)) { // value is number } すでに Predicate を中心に Code を書いているなら、これは便利やと思う。 ただし、is-kit は Full Schema Library を目指しているわけではない。 safeParse() が返すのは小さな Success / Failure の Result。Field ごとの詳細な Issue、Transformation、Schema Metadata を提供するものではない。 そうした機能や Framework Integration が必要なら、僕なら Schema Library を選ぶで。🔧 それと、Type Level で見つかった点も書いておく。 struct() と arrayOf() は Readonly な構造を Inference する。 だから Runtime の Shape は User と一致していても、readonly string[] は Mutable な string[] に Assign できない。 この違いは Cast で隠さず、Type Test でも明示してるで。🔧 3. 🏃 Runtypes — Predicate ではなく Validator Object Runtypes はもう一段違う考え方や。 単に Boolean を返す Predicate ではなく、一つの Runtime Validator Object に複数の使い方が用意されてる。 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); 普通の Predicate と違って、同じ Validator に複数の使い方がある。 guard() で Narrowing check() で失敗時に Throw parse() で Parsing inspect() で構造化された Failure Data を取得 ただし check() と parse() は同じ意味ではない。Parser の挙動によって Object の扱いが変わる場合もある。 単純な Boolean Guard とは違う Runtime の仕組みやな。🏃 4. 🤖 Valibot — Modular な API の Schema Validation Valibot からは、いよいよ Schema Library らしくなってくる。 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); Valibot は Schema Validation、構造化された Issue、Transformation Pipeline を、Modular な Function-based API で組み合わせられる。 この設計は Tree Shaking とも相性がええ。 さらに v.is() は Type Guard として使えるで。🤖 ただし v.is() 自体は詳細な Issue を返さず、Parsing のように Transformation を適用するわけでもない。 5. 💎 Zod — Schema が Ecosystem の一部になるとき 続いて 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); // This schema has no transforms, so its output type is safe for the input too. export const isUser = (input: unknown): input is User => parseUser(input).success; Zod に組み込みの is() Method はないけど、この Adapter は元の input を Narrowing できる。 今回の Schema には Transform がないからやな。 Transform を含む Schema なら、元の Input と Output の Type が違う可能性があるので、safeParse().data を Typed Value として使うほうが安全。 ここは混同したらアカンところ。 さて、Zod の魅力は User を Validate できること自体ではない。 それなら今回のどの実装でもできる。 大きいのは Schema を取り巻く Ecosystem や。 Form Integration API Tooling Schema Conversion Metadata Codec OpenAPI 関連 Tooling Framework Integration Schema が Application Infrastructure の一部になっているなら、こうした Ecosystem は強い選択理由になる。 String Length で気になったこと 今回、これは使わなかった。 z.string().min(1).max(100); なんでかって? 今回の Contract は、 1〜100 UTF-16 Code Unit やから。 Zod の組み込み String Length Check は Unicode Code Point を数えるので、Contract に合わせるため .refine() と JavaScript の string.length を使った。 後で出てくる Emoji の Fixture で、違いがはっきりするで。😹 Bundle Size はどうなん? 「Valibot は小さいけど、Zod は大きい」 そう単純にも言えへん。Zod には Functional で Tree-shakable な Zod Mini もある。 後半の Bundle Size は 通常版 Zod の結果で、Zod Mini の測定ではないで。 また Zod は z.compile() で Runtime の Validation Logic を最適化できるけど、今回は有効にしていない。 これは typia の Build 時の TypeScript Transform とは別の仕組みや。💎 6. 🚢 ArkType — Runtime Schema が TypeScript っぽかったら? ArkType は Schema の書き方がかなり違う。 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"; どっちが絶対に優れている、という話ではない。 同じ Runtime Contract を表現するための、違う書き方や。 TypeScript っぽい式が自然に読める Team なら、ArkType はかなり読みやすいと思う。 allows() は Type Guard として使えるし、Schema を直接呼ぶと Validated Output か Error Collection を取得できる。 もう一つ面白いのは、new Function が使える Environment なら Runtime で Traversal Logic を Precompile できること。 つまり ArkType も、すでに最適化された Validation Logic を作っている。 でも typia と同じ Architecture ではない。 ArkType は Runtime の Type Representation から始まる。 typia は Build 時の TypeScript Type から始まるんや。🚢 7. 🚀 typia — Build 時に Validator を生成する 今回の中で、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>(); typia は TypeScript の Type と Tag を、Build 時に専用の Validation Code に変換する。Runtime Schema Object をその場で解釈する方式とは違う。🚀 ただし、Trade-off もある。 Build Integration が必要。 今回の公式 Setup は TypeScript 7 と ttsc / typia の Transform Integration を使う。 普通の tsc だけでは Transform は走らない。 僕の検証環境でも、最初の typia Build では Go の Source Plugin を Compile する必要があり、数分かかった。 この Setup Cost は Runtime Benchmark には入っていないけど、採用を決めるときには無視できへんよな。 それから、組み込みの MinLength / MaxLength は今回の UTF-16 Contract と一致しなかった。 だから name には Custom Tag を使ってる。 またしても「同じような Rule に見えるのに、実際の意味が違う」という話や。🚀 😹 えっ、String Length って同じじゃないん? 最初に「えっ?」となったのがこの Test。 "😀".repeat(51).length; // 102 51 Unicode Code Point なのに、102 UTF-16 Code Unit になる。 今回の Contract は最大100 UTF-16 Code Unit。だから Reject されるべきや。 実際、最初から Reject したのは手書き Guard、is-kit、Runtypes、Valibot、ArkType。 一方、最初の Zod と typia の Sample は Accept した。なんでやねん!😹 組み込みの Length Constraint が Unicode Code Point を数えていたからや。 別にどっちが間違いというわけではない。 違う Runtime Contract を表している というだけ。 さらに、人間から見た「1文字」とも必ずしも一致しない。Grapheme Cluster まで考えたら Unicode はもっと面白いことになる。😹 だから、最終的には Zod を .refine() に、typia を Custom Tag に変更した。 今回の比較で、個人的に一番大きかった発見がこれ。 同じように見える Schema が、同じ Runtime Contract を表しているとは限らない。 で、結局何を比較してるん? ここまで見ると、この7つは単なる交換可能な Library には見えへんよな。 そもそも Runtime Model が違う。 まず表で整理してみよう。 Approach Source of truth Natural success API Structured failures Transformation model Compilation / build requirement Handwritten guard Type + handwritten check Predicate Manual Manual None is-kit Composed guards Predicate / safeParse Basic valid/invalid result Guard-oriented None Runtypes Runtype object guard / check / parse Yes Parsers None Valibot Schema functions is / safeParse Yes Schema pipelines None Zod Schema objects parse / safeParse Yes Transforms / codecs No build transform; optional runtime z.compile() ArkType Runtime type expressions allows / invocation Yes Morphs No build transform; runtime precompilation when available typia TypeScript types and tags Generated predicates / validation results With validation APIs Build-generated functions Build transform required だから、これらを「上位互換への進化段階」みたいに考えるのは違うと思うようになった。 Schema Library は「より優れた Type Guard」とは限らない。 Generated Validator も「より優れた Schema」とは限らない。 問題が重なる部分はあるけど、解決している Layer が違うんやな。 ✅ 実際に何が Pass したん? 同じ35個の JSON Fixture で7つの実装を検証した。 35 fixtures × 7 validators = 245 checks 245個、すべて Pass! ✅ 境界値、Emoji の Length、間違った型、Missing Field、余分な Key などを確認してる。Harness では Type Narrowing、Input Mutation、Build、Bundle 後の動作も検証した。 再現してみたい人はこちら。😺 Harness、Lockfile、Fixture、Probe、Benchmark の結果は、固定 Commit の typescript-validator-comparison に公開してるで。 git clone nyaomaru/typescript-validator-comparison cd typescript-validator-comparison git checkout --detach b26350d01fd18cb77d55a95e2f3b112657f0d305 corepack pnpm install --frozen-lockfile corepack pnpm verify 今回の JSON に相当する Contract では7つの実装が一致した、という結果や。あらゆる JavaScript Value に対する完全な一致を意味するわけではないで。 🧩 もう一つの驚き。「Exact」って本当に Exact なん? 普通の JSON に収まらない Value も Test してみた。 表に出す4つの Probe は、この Code に対応してる。 const sparseTags = { ...valid, tags: new Array(2) }; const inheritedFields = Object.create(valid); const extraSymbol = { ...valid, [Symbol("extra")]: true }; const extraUndefined = { ...valid, extra: undefined }; Repository では、このほかに Non-enumerable Property や Throw する Getter を含む、さらに9つの Probe を実行している。 この4つの結果はこんな感じ。 Probe Native is-kit Runtypes Valibot Zod ArkType typia Sparse tags array Accept Reject Accept Reject Reject Reject Accept All fields inherited Reject Reject Reject Accept Accept Accept Accept Extra symbol property Accept Accept Reject Accept Accept Reject Accept Extra key with value undefined Reject Reject Reject Reject Reject Reject Accept さらに、Test した余分な Non-enumerable Property は7つすべてで Accept された。 そして Throw する Getter を読み取るケースでは、7つとも Throw した。 どれかの Library が壊れている、という話ではない。 「Exact Object Validation」には、対象範囲の定義が必要 ということや。 手書き Guard の Object.keys() は Symbol や Non-enumerable Key を見ないし、Array.every() は Sparse Array の Hole を Skip する。 JSON Data なら合理的でも、あらゆる JavaScript Object に共通する Rule ではないんやな。😺 📦 Bundle Size さて、みんな気になる Bundle Size や!😺 Browser 向けの各 Entry は validate だけを Export している。 設定はすべて同じ esbuild の条件に揃えた。 Browser Platform ESM ES2022 Minify Tree Shaking Legal Comment を削除 Raw、gzip、Brotli の Size を測った結果がこれ。 Validator Raw bytes gzip bytes Brotli bytes handwritten 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 結果を見る前に、3つだけ注意してな。👇 Standalone Cost: 独立した Bundle の Size。既存 Application に Schema を1つ追加する Cost とは違う。 通常版 Zod: Zod Mini は測っていない。 Generated Code: typia の小さな Bundle にも生成 Code と Helper は含まれる。 それでも、Architecture の違いが表れているのは面白いな。😺 ⚡ Runtime Performance 同じ Contract で、簡単な Steady-state Benchmark も回してみた。 Case は正常な User、間違った id、間違った profile.active の3つ。 各 Library を別々の Node Process で実行し、20,000 Call の Warmup 後に、50,000 Call × 9 Sample を測定した。Schema の生成は測定 Loop の外や。 結果は 1 Call あたりの Median(ns)。括弧内は Min〜Max。 これは Microbenchmark なので、V8 の最適化や API の処理内容の違いに注意してな。 Validator Valid (ns/call) Wrong id (ns/call) Wrong profile.active (ns/call) handwritten 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 がどんな状況でも最速!」 と読むのは違うで。😹 同じ小さな Object を繰り返しているので、2.0 ns のような値を実際の Application にそのまま一般化はできへん。 それでも、面白い傾向はあった。👇 🚀 typia はこの Steady-state Test で Overhead が小さかった。 🚢 ArkType は Invalid Input の処理が特に速かった。 🔧 is-kit は間違った id を早い段階で Reject した。 🏃 Runtypes は今回の Loop では比較的 Cost が高かった。 Architecture と Failure Path の両方が大事、ということやな。 🤔 ほな、結局どれを使う? ここまで検証した結果、僕の選び方は結構シンプルになった。 やりたいこと 候補 一箇所だけ Type Check ✍️ 手書き Guard 再利用できる Guard を Compose 🔧 is-kit Rich な Validator API 🏃 Runtypes Modular な Schema Validation 🤖 Valibot Schema の Ecosystem や Integration 💎 Zod TypeScript っぽい Schema Syntax 🚢 ArkType Build 時に Type から Validator を生成 🚀 typia 万人向けの勝者はおらへん。 😺 今回一番驚いたのは、Bundle Size や Speed ではなく、7つの Validator が、ちょっとずつ違う Runtime Contract を表現し得る ということ。 だから Library を選ぶ前に考えたい。 何を Validate して、結果として何が必要なん? 一番小さくて速いものが、その Boundary にとって一番合うとは限らへん。 みんななら、どれを使う?🤔 最後まで読んでくれてありがとう〜!🤲🦐

  5. DEV · · @nyaomaru

    I Compared 6 TypeScript Validation Libraries. Here's What Surprised Me 🤔 Hoi hoi! 👋   I'm nyaomaru, a frontend engineer and OSS developer working mostly with TypeScript. When we need runtime validation in TypeScript, the conversation often turns into this 👇 > "Which library should I use?" - Zod? - Valibot? - ArkType? - typia? Or maybe, just write a type guard? I maintain is-kit, so I think about this boundary quite a lot. And recently I started wondering > Are we comparing libraries that are actually solving the same problem? For example, if all I want is this if (isUser(input)) { input.name; } do I really need a schema system? But if I want: - structured validation errors - transformations - parsing - schema metadata - framework integrations then a small boolean guard is probably not enough either. So instead of asking "Which validator is best?", I tried a different comparison. I implemented the same User validation with: - a handwritten guard with a TypeScript predicate - is-kit - Runtypes - Valibot - Zod - ArkType - typia Then I compiled, tested them against the same fixtures, measured standalone browser bundle size, and ran a small steady-state runtime benchmark. The hardest part was not making seven validators compile. It was making seven validators mean the same thing. Even “a string with at most 100 characters” can describe different runtime contracts. So, let's look at what each tool is actually buying us. 😺 !TypeScript Validation Battle --- 🧪 The User we will validate Here is the shared type. type User = { id: string; name: string; age: number; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; }; And the runtime rules are: - id must be a string - name must contain 1–100 UTF-16 code units - age must be an integer from 18 to 120 - role must be exactly "admin" or "user" - tags must be an array of strings - profile.bio must be a string - profile.active must be a boolean - extra keys are rejected at the root and inside profile - values are not coerced For this comparison, the main input domain is ordinary JSON-decoded data. That matters because JavaScript can also give us strange things like inherited properties, sparse arrays, symbols, getters, and non-enumerable keys. We'll come back to those later. --- 1. ✍️ Handwritten Guard — Sometimes a Guard Is Enough Let's start with the boring option, a handwritten JavaScript guard with a TypeScript predicate. No validation library. 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" ); }; And then declare const input: unknown; if (isUser(input)) { input.name.toUpperCase(); } This is still very good. For one local boundary, a handwritten type guard has a huge advantage > There is no extra model to learn. It is just JavaScript runtime checks plus a TypeScript predicate. But the cost starts to appear when your codebase grows this isUser; isProfile; isRole; isNonEmptyString; isAdultAge; isStringArray; isApiResponse; isConfig; isFeatureFlag; Now you are not writing "one guard" anymore. You are maintaining your own tiny guard library. And this is where the next option starts to make sense. 👍 --- 2. 🔧 is-kit — When Small Guards Become Reusable Building Blocks This is the problem I built is-kit around. I didn't want a schema-first workflow for every narrowing problem. I wanted small guards that stay small, but compose cleanly. 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); The important part is that the pieces remain normal guards const names = values.filter(isName); if (isAge(value)) { // value is number } That is useful when your codebase already thinks in terms of predicates. And this is also where I would draw the line. safeParse() gives a small success/failure result around a guard. It does not turn is-kit into: - a field-level issue system - a transformation pipeline - a schema metadata system - an OpenAPI ecosystem - a form integration layer If those are the actual requirements, I would rather use a schema library than try to make a guard toolkit become one. One type-level detail also showed up in the verification, struct() and arrayOf() infer readonly structures. So the runtime shape matches our User, but readonly string[] is not assignable to mutable string[]. That difference is real, and the type tests make it explicit instead of hiding it with a cast. 🔧 --- 3. 🏃 Runtypes — A Validator Object, Not Just a Predicate Runtypes moves one level further. Instead of just a boolean predicate, you get a runtime validator object with several ways to consume it. 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); This feels different from a guard toolkit. A predicate mainly answers > Is this value a User? A Runtype can also answer that question, but the same object can: - narrow with guard - throw with check - parse with parse - expose structured failure data Also, check() and parse() should not be treated as synonyms. Runtypes supports parser behavior, and object handling can differ between validation and parsing contexts. That is exactly the kind of runtime machinery you do not have in a plain predicate. 🏃 --- 4. 🤖 Valibot — Schema Validation With a Modular API Valibot is where the comparison starts looking more "schema-like". 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); This gives us: - schema validation - type inference - structured issues - parsing APIs - transformation pipelines But the API is still function-based and modular. That is a big part of Valibot's design. Instead of every capability living as methods on large schema objects, you compose independent functions. That makes tree shaking a first-class part of the normal API. And unlike a pure schema-only mental model, Valibot also exposes v.is() as a type guard. So schema validation and ordinary TypeScript narrowing are not mutually exclusive. One caveat: v.is() is the guard path. It does not expose validation issues or apply transformations like parsing does. 🤖 --- 5. 💎 Zod — When the Schema Is Part of a Bigger Ecosystem Now 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); // This schema has no transforms, so its output type is safe for the input too. export const isUser = (input: unknown): input is User => parseUser(input).success; Zod does not expose a built-in is() method, but this adapter can narrow input because this schema has no transforms. With a transforming schema, safeParse().data is the safe typed value: the original input and output types may differ. Zod is not interesting because it can validate a User. All of these libraries can do that. The real value is everything around the schema: - form integrations - API tooling - schema conversion - metadata - codecs - OpenAPI-related tooling - framework integrations If your schema is already becoming part of application infrastructure, that ecosystem matters. 💎 There is one important detail in this sample though. I did not use this z.string().min(1).max(100); Why? Because our contract says > 1–100 UTF-16 code units Zod's built-in string length checks use Unicode code points, so this sample uses .refine() with JavaScript's string.length to preserve the stated UTF-16 contract. The shared emoji fixture demonstrates why below. What about bundle size? It is too easy to write > Valibot is small, Zod is large. That is not a good 2026 comparison anymore. Zod also has Zod Mini, which uses a more functional, tree-shakable API. The bundle table later in this article measures regular Zod, not Zod Mini. So it should not be read as Valibot versus the smallest possible Zod setup. Zod can also compile optimized validation logic at runtime with z.compile() and new Function. I did not enable it here. That makes this distinction more useful than the broad label “generated validation” runtime schema compilation vs build-time generation from TypeScript types That distinction becomes important when we get to typia. --- 6. 🚢 ArkType — What If the Runtime Schema Looked Like TypeScript? ArkType has a very different authoring model. 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; I like this comparison z.int().min(18).max(120); vs "18 <= number.integer <= 120"; Neither is "better". They are different ways to express the runtime contract. If your team already thinks naturally in TypeScript-like expressions, ArkType is very readable. allows() also works as a type guard. And calling the schema directly gives you validated output or an error collection. Another interesting part: ArkType can precompile optimized traversal logic at runtime when the environment allows new Function. So ArkType is already doing specialization. 🚢 But it is still not the same architecture as typia. ArkType starts from a runtime type representation. typia starts from TypeScript types during the build. --- 7. 🚀 typia — Generate the Validator During the Build typia is the outlier in this comparison. 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>(); This is not a runtime schema object. The source of truth is the TypeScript type plus typia's tags. Then typia's build transform turns typia.createEquals<User>(); into specialized validation code. That means there is no general runtime schema interpreter walking a User schema object. But there is a trade-off > You need build integration. The current official setup uses TypeScript 7 and ttsc / typia's transform integration. Stock tsc alone does not run the transform. In my verification workspace, the first typia build also had to compile a Go source plugin and took several minutes. That setup cost is not part of the runtime benchmark, but it is absolutely part of the adoption decision. Also, typia's built-in MinLength / MaxLength did not match our UTF-16 contract. So I used a custom tag for name. Again, same-looking validation rule, different semantics. 🚀 --- 😹 Wait, Even "String Length" Was Different? This was the first test that made me stop. "😀".repeat(51).length; // 102 JavaScript's string.length counts UTF-16 code units. So with our contract maximum = 100 UTF-16 code units this should fail. And it did fail in: - handwritten guard - is-kit - Runtypes - Valibot - ArkType But the original Zod and typia samples accepted it. Why? Because their built-in length constraints count Unicode code points instead. For this string 51 emoji = 51 code points = 102 UTF-16 code units Neither behavior is wrong. They are just different contracts. And neither one necessarily matches "what a human sees" either, because grapheme clusters make Unicode even more fun. 😹 So I changed the Zod sample to .refine() and typia to a custom tag. That was probably the most useful lesson in the whole comparison 👇 > Equivalent-looking schemas are not automatically equivalent runtime contracts. --- So What Are We Actually Comparing? At this point, these no longer look like seven interchangeable libraries. They are different runtime models. | Approach | Source of truth           | Natural success API                       | Structured failures       | Transformation model       | Compilation / build requirement                           | | --------- | -------------------------- | ------------------------------------------ | --------------------------- | -------------------------- | ---------------------------------------------------------- | | Handwritten guard | Type + handwritten check   | Predicate                                 | Manual                     | Manual                     | None                                                       | | is-kit   | Composed guards           | Predicate / safeParse                   | Basic valid/invalid result | Guard-oriented             | None                                                       | | Runtypes | Runtype object             | guard / check / parse               | Yes                         | Parsers                   | None                                                       | | Valibot   | Schema functions           | is / safeParse                         | Yes                         | Schema pipelines           | None                                                       | | Zod       | Schema objects             | parse / safeParse                     | Yes                         | Transforms / codecs       | No build transform; optional runtime z.compile()         | | ArkType   | Runtime type expressions   | allows / invocation                     | Yes                         | Morphs                     | No build transform; runtime precompilation when available | | typia     | TypeScript types and tags | Generated predicates / validation results | With validation APIs       | Build-generated functions | Build transform required                               | This is why I no longer like thinking about them as a simple upgrade ladder. A schema library is not "a better type guard". A generated validator is not "a better schema". They solve overlapping problems at different layers. --- ✅ What Actually Passed? The complete harness, lockfile, fixtures, probes, and generated result files live in typescript-validator-comparison. The article links to that immutable commit, rather than to a moving branch. To reproduce the recorded snapshot: 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 runs lint, strict type checking, fixtures, bundle measurement, benchmarks, JavaScript-only probes, and report generation. The result tables below are that run's saved snapshot, reruns on another machine will naturally produce different timing numbers. I pinned the versions and ran the same fixture matrix against all seven implementations. Environment Date:       2026-10-23 Node:       24.22.1 TypeScript: 7.0.2 pnpm:       11.22.0 ttsc:       0.31.3 tsbuild:    0.28.2 Packages | Package   | Version | | --------- | -------- | | 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   | The shared matrix contains 35 JSON fixtures. That includes: - valid users - age boundaries - name boundaries - emoji length cases - wrong primitive types - missing fields - invalid roles - invalid array elements - extra root keys - extra nested keys - non-object roots All seven matched the expected result. 35 fixtures × 7 validators = 245 boolean checks All 245 passed. The harness also checks input mutation, richer APIs, strict TypeScript compilation, lint, builds, narrowing, and bundled output behavior. So the implementations agree for the JSON-shaped contract in this article—not necessarily for every JavaScript value. --- 🧩 One More Surprise: "Exact" Is Not Always Exact I also tested values outside the normal JSON-shaped input domain. The four probes in the table mean exactly this: const sparseTags = { ...valid, tags: new Array(2) }; const inheritedFields = Object.create(valid); const extraSymbol = { ...valid, [Symbol("extra")]: true }; const extraUndefined = { ...valid, extra: undefined }; The repository runs nine more probes, including non-enumerable properties and a throwing getter. Here is what happened for these four | Probe                             | Native | is-kit | Runtypes | Valibot | Zod     | ArkType | typia   | | --------------------------------- | ------ | ------ | -------- | -------- | ------- | -------- | ------- | | Sparse tags array               | Accept | Reject | Accept   | Reject   | Reject | Reject   | Accept | | All fields inherited             | Reject | Reject | Reject   | Accept   | Accept | Accept   | Accept | | Extra symbol property             | Accept | Accept | Reject   | Accept   | Accept | Reject   | Accept | | Extra key with value undefined | Reject | Reject | Reject   | Reject   | Reject | Reject   | Accept | All seven accepted the extra non-enumerable property I tested. All seven threw when reading a getter that throws. This does not mean one library is "correct" and the others are broken. It means > "Exact object validation" needs a scope. For example, my native guard uses Object.keys(value); That only sees enumerable string keys. And this value.tags.every(...) skips sparse array holes. That is perfectly reasonable for JSON data. It is not a universal policy for every weird object JavaScript can create. This is another reason I think validator comparisons should state the expected input domain. --- 📦 Bundle Size Now the fun table. 😺 Each browser-targeted entry exports only validate. All of them were bundled with the same esbuild settings: - browser platform - ESM - ES2022 - minification - tree shaking - legal comments removed Then I measured raw, gzip, and Brotli output. | Validator | Raw bytes | gzip bytes | Brotli bytes | | ---------- | --------: | ---------: | -----------: | | handwritten 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 | A few important caveats. This is standalone cost If your app already ships Zod, the marginal cost of one more schema is not 26 KB gzip. This table measures isolated entry bundles. This is regular Zod Not Zod Mini. So this table does not answer > Valibot vs Zod Mini? Generated does not mean zero bytes typia's final guard bundle was only 772 bytes raw in this test. But it still shipped generated code and a uint32 helper. "Generated validator" does not mean "free validator". Still, the architectural difference is very visible here. --- ⚡ Runtime I almost did not include runtime numbers because validator benchmarks get misleading very quickly. But since the workspace already had the same contract and fixtures, I ran a small steady-state benchmark. Three cases: - valid user - wrong id - wrong nested profile.active Each library ran in a separate Node process. Each case used: - 20,000 warmup calls - 9 measured samples - 50,000 calls per sample Schemas and validators were created outside the measured loop. Results are median nanoseconds per call, with min–max in parentheses. | Validator |                  Valid |              Wrong id |   Wrong profile.active | | ---------- | ---------------------: | ----------------------: | -----------------------: | | handwritten 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) | Please do not read this as: > typia is the fastest validator. This is a microbenchmark. The same small object shapes are validated repeatedly, so V8 can optimize very aggressively. I would not interpret a 2.0 ns result as a portable "cost of validation" in a real application. The success APIs are also different: - handwritten guard uses a predicate - is-kit uses a predicate - Runtypes uses .guard() - Valibot uses is() - Zod uses safeParse(...).success - ArkType uses allows() - typia uses a generated equals guard Those operations do not all perform exactly the same work. What the benchmark _does_ show is that architecture and failure path matter. For example: - the handwritten guard pays for exact-key checks before rejecting the wrong id - ArkType's invalid paths were much cheaper than its valid path - typia's generated guard had very low steady-state overhead here - Runtypes' richer validation machinery costs more in this particular hot loop That is useful. A universal speed ranking is not. --- So... Which One Would I Use? 🤔 After doing all of this, my decision tree is actually pretty simple. Just one local narrowing check? Use a handwritten guard. if (isUser(input)) { // input is User } No dependency is a feature when the problem is small. Lots of reusable guards? This is where I use is-kit. 🔧 values.filter(isName); if (isAge(value)) { // ... } The problem is not "I need a schema". The problem is "I keep rebuilding the same predicates and composition helpers". Want a validator object? Runtypes is interesting when you like having one runtime object that can: 🏃 guard check parse inspect assert Want schema validation with a modular API? Valibot. 🤖 Especially if client bundle size and tree shaking are important parts of the design. Want schemas tied into a bigger ecosystem? Zod. 💎 That is probably its strongest reason to exist in many applications. And if bundle size is a concern, evaluate Zod Mini separately. Want the schema syntax to feel like TypeScript? ArkType. 🚢 "18 <= number.integer <= 120"; is a very different authoring experience from chained schema methods. Want TypeScript types transformed into validators during the build? typia. But I would treat the build integration as part of the decision, not a footnote. --- Final Thought These tools overlap, but they live at different layers. The hard part is not making seven validators compile. It is making seven validators mean the same thing. So before comparing package size or benchmark screenshots, ask what the boundary needs to return and what runtime machinery is worth carrying. > Use the smallest model that still represents your boundary well. Then, which one will you use? Thank you for reading! 🤲

    64 reactions · 26 comments
  6. DEV · · @merbayerp

    1,391 Tags Later, 931 Still Describe One Post Each On 4 June I closed two doors at once. I pulled tag pages out of the sitemap — thin listings were inflating sixty per cent of it and eating crawl budget. The same day, for the same reason, I put a limit on the production side too: a canonical vocabulary of 45 tags and a cap of five tags per post. The commit message says exactly that: "restrict AI tag generation to a canonical ~46-tag vocabulary." It worked. Two hundred posts shipped in July and added exactly one new tag to the vocabulary. Then in September the tag count started climbing again, and it took me four months to notice. Today I sat down and counted: 1,370 Turkish posts, 1,391 distinct tags. There are more tags than there are posts. The inventory first Numbers from today's main tree, drafts excluded. The live feed.json gives the same — I counted both separately: | Measurement | Value | |---|---| | Turkish posts | 1,370 | | Distinct tags | 1,391 | | Total tag usages | 5,831 | | Tags per post | mean 4.26 · median 5 | | Tags appearing on exactly one post | 931 (66.9% of distinct) | | Appearing on at most two posts | 1,112 (79.9%) | | Share of the 15 most frequent tags | 1,809 usages = 31.0% | The head of the distribution is healthy: yazilim on 245 posts, operations 148, security 143, kariyer 141, linux 140. Fifteen tags carry a third of all usage. The problem isn't at the head; it's in the tail. I also counted where the tail came from: of the 931 single-post tags, 708 sit on posts published before 4 June and 223 after. So the vocabulary I installed that day genuinely held. The problem is the legacy stock — and the fact that the gate stopped working four months later. I didn't start deleting at this point. It felt more honest to ask something else first: who reads this field? Because calling something useless assumes someone was looking at it. Who reads a tag? I found eight places in the code that consume tags. I measured each one separately, because "tags don't do anything" is just a feeling until you check. Two of the results did not confirm what I expected. !Diagram Related posts. The scoring is simple: same category +3, each shared tag +1, top three win. I have to be honest here: taken as a whole, tags work. In 1,358 of 1,370 posts at least one candidate rises above the category-only score thanks to a tag. In only 12 posts does the top score stay at 3 — and there the selection is decided by array order, because hundreds of candidates are tied. Throwing the field away would be wrong. But by definition, a tag that lives on one post can never enter this scoring; it has nobody to share with. The tag page. I wrote this part up last week from the Search Console side, so I'll keep it short: the page ships noindex,follow, it left the sitemap on 4 June, and robots.txt closed it on 6 June. One detail from that post applies here too — and it demolishes what I'd been assuming was three layers of protection: per Google's own documentation, for noindex to work the page must not be blocked in robots.txt. A crawler that can't fetch the page never reads the noindex line. The second rule silences the first. The practical result: a tag page is neither in the sitemap nor crawlable, which leaves the human click as its only real visitor. And when you open the page of a tag that lives on one post, what do you see? A heading saying "1 post", and below it a single card: the post you just left. 931 times. A smaller finding: the card component prints only the first three tags, so 2,102 tag usages (36%) appear on no card at all. And tag links are generated without a trailing slash in four places (under the post, the homepage tag cloud, the blog listing); asking for /tags/olcum earns you a 301 to /tags/olcum/. Only the card component writes it correctly. meta keywords and schema.org. Both are still in the live HTML: <meta name="keywords" content="yazilim-kariyeri, dokumantasyon, olcum, git, surec"> and the same array inside the JSON-LD. Google's documentation on supported meta tags isn't even polite about it: the keywords tag has "no effect on indexing and ranking at all." On the structured-data side, the properties they list for Article/BlogPosting are author, dateModified, datePublished, headline and image — keywords isn't there. Harmless; but the story I'd told myself — "I write tags for SEO" — has no ground under it. Social hashtags. Here there are real consumers, two of them: the Bluesky and LinkedIn code each turn the first five tags into hashtags. 809 single-post tags fall inside that window and 804 sit on posts that were actually shared, so they went out. A hashtag's job is to take you into a feed; a hashtag with one post is a room with a door and nothing behind it. Search. The search text is assembled from title + tags + description + body. A tag only contributes something new if it isn't already in the prose — and "already there" depends on your matching rule: under strict substring matching, 567 of the 931 are present in their own post's text; allowing Turkish suffixes and separator differences, 677. So somewhere between a quarter and a third of the tail genuinely adds a new searchable word — things like invisible workload or customer trust. That's a more honest sentence than "none of it contributes." RSS, JSON Feed and llms-full.txt. I saved the real surprise for last, because before measuring I hadn't even counted these three. All of them emit every tag of every post, with no truncation: the <category> lines in RSS, the tags array in feed.json, the - Tags: line in llms-full.txt. The tail that the card hides and the hashtag window cuts at five is in full circulation here. And llms-full.txt is the very file my robots.txt policy deliberately invites AI assistants to read. So the widest consumer of the field I'd written off as unread turned out to be in the place I never looked. When the gate stopped working New tags by publication month — and I'm giving the table from the start, because the real story isn't where I'd cut it: | Month | Posts | New tags | Per post | |---|---|---|---| | 2026-04 | 368 | 602 | 1.64 | | 2026-05 | 274 | 422 | 1.54 | | 2026-06 | 232 | 51 | 0.22 | | 2026-07 | 200 | 1 | 0.01 | | 2026-08 | 97 | 49 | 0.51 | | 2026-09 | 96 | 140 | 1.46 | | 2026-10 | 71 | 70 | 0.99 | Had I started the table at June, I'd have had a dramatic "September explosion" on my hands. Add the April and May rows and the story changes: September isn't an explosion, it's a return to the pre-4-June baseline. So the question isn't "what happened in September?" but "why did the gate I installed in June stop working?" The answer is mechanical. The canonical vocabulary and the five-tag cap live inside the script that generates posts. On 3 September the topic queue ran dry — I wrote about that separately, because it broke my category rotation the same day — and posts started coming out of a different path that writes the file directly. That path never calls the old script, so it walks straight past the vocabulary and the cap. There are 14 posts carrying nine tags, and all of them are dated 19–28 September: by then the ceiling was already gone. I installed that gate, and I didn't remove it. I just installed it inside the corridor — and when the corridor changed, the gate went with it. The root cause sits one line away So why did nothing catch it? Because the validation isn't in the schema. Inside src/lib/blog-frontmatter-schema.mjs, two neighbouring lines read: category: z.enum(BLOG_CATEGORIES), // closed: technology, tutorials, career, life tags: z.array(z.string()).min(1), // at least one — no ceiling, no vocabulary I locked category to four values. For tags I never wrote anything beyond "there should be at least one." Because the vocabulary decision lived in the generator rather than the schema, this file — which inspects every post on the publishing path — waved 1,391 distinct values through without complaint. I'd seen a similar pattern when I tested my own instruction files against the code: the place nobody audits is the place that quietly drifts. The difference this time: the audit existed, it was just in the wrong layer. Same concept, two separate tags There's no normalisation either. Collapsing case, hyphen, space and Turkish-character differences, 180 clusters are represented by more than one spelling, covering 386 tags. (Drop the Turkish folding and you're left with 156 clusters / 333 tags — that fold is exactly what separates yazilim from yazılım.) The expensive ones: - devops on 124 posts · DevOps on 15 - sistem-mimarisi 123 · sistem mimarisi 5 · Sistem Mimarisi 1 - ai 74 · AI 21 - indie-hacker 95 · indie hacker 10 The tag query uses exact matching (tags.includes(tag)), so these really are different things. I tried it live: /tags/DevOps/ and /tags/devops/ both return 200 and show two separate lists. In the related-posts score those two posts behave as though they share nothing. The hashtag formatter then carries the same split somewhere else: devops becomes #Devops while DevOps becomes #DevOps, yet sistem-mimarisi and sistem mimarisi both collapse into #SistemMimarisi. There's a bilingual duplication on top: kariyer 141 / career 82, security 143 / guvenlik 51, rehber 118 / tutorial 3. Which one is correct is debatable; that using both is wrong isn't. My own note misled me Into the 11 May commit where I moved tag pages to SSR, I wrote this: > Prerendering tag pages per unique tag was producing ~100 pages. In the same commit message I counted posts precisely: "985+ posts x 2 languages = ~1970 pages." I counted one field and estimated its neighbour. I checked out that commit's tree and counted: 910 Turkish + 915 English = 1,825 tag pages. My estimate was off by a factor of eighteen. The fix still worked. But because of that "~100 pages" note I remembered the problem as a small one for months — until three and a half weeks later, on 4 June, I measured the same thing again and wrote the real number into the code. The rounded figure kept feeding the memory even while the measured one sat in a neighbouring file. One more note: in my post on 20 October I wrote "1,261 distinct Turkish tags." Counting today from both the git tree and the live feed.json gives 1,391, and the tree on that date held 1,375. A gap that size can't close in six days, so I'll redo that measurement. The number in this post is the correct one. Four questions I now ask before adding a field For any metadata field — frontmatter, database column, tag, it doesn't matter: 1. Can I name its consumers? I thought there were five; there are eight, and I'd never counted the three widest. 2. Is the vocabulary closed? If not, why not — does it genuinely need infinite variety, or was I avoiding a decision? 3. Which layer does the validation live in? My gate was inside a generation script, not beside the schema. The path changed and the gate went with it, and the schema felt nothing. Put the rule next to whatever records the data, not next to whatever produces it. 4. Does the field leave the building? Even if I close the vocabulary today, the RSS history, the hashtags already posted and the crawlers that have fetched llms-full.txt are out of my hands. Metadata doesn't come back once it has escaped. I also left myself two cheap numbers: the single-use ratio (66.9% here) and the share of the fifteen most frequent values (31.0%). If both climb at once, the vocabulary is coming apart. What I'm going to do Order matters, because clearing stock doesn't fix a mechanism. First I'll move the validation into the schema (an approved list plus lowercase normalisation), so that every path writes through the same gate. Then I'll merge the 180 spelling variants. Pruning the tail comes last — and the safety net for that is already in place: a tag address with no posts 301s to /blog/, so I can delete without creating dead links. I'm not planning to remove tag pages altogether: the listing for a tag like olcum, with 62 posts, is a genuinely useful page. What needs removing isn't the page, it's the tail that makes the page meaningless. The real lesson: I started writing this as "I kept filling a field nobody reads," and the measurement stopped me twice. I had closed the gate — but inside the corridor. And the widest reader of the supposedly unread field turned out to be three machine-facing files I had never looked at. Filling a field doesn't prove it's read; assuming it isn't read doesn't prove that either. Both are things you measure. One small note: this post's own tags were picked from the existing vocabulary. All five have been used before; I didn't add a new one. Official Sources - Meta tags and attributes that Google supports — where it states the keywords meta tag has no effect on indexing or ranking. - Article (Article, NewsArticle, BlogPosting) structured data — the properties Google uses for Article; keywords isn't among them. - Large site owner's guide to managing your crawl budget — how low-value/duplicate URLs affect crawl budget, and why noindex is the wrong tool for crawl management. - schema.org: keywords — the property's definition and how multiple values are supplied. - Zod — schema validation; the difference between a closed value set with z.enum and a free list with z.array(z.string()).

    1 reaction
  7. Hacker News · · hek2sch

    Zod maintainer refuses v3 CVE backport because advisory is agent-written

    1 point
  8. Habr · · @ReturnVoid

    Вы полюбите перегрузку в TypeScript Если вы открыли эту статью, значит, как минимум, слышали о перегрузках в TypeScript, возможно даже пытались их применять, но быстро разочаровались и забросили. Может показаться, что это какая‑то переусложненная избыточная магия. Однако, на самом деле вы сталкиваетесь с перегрузками каждый день часто даже не осознавая этого. Вызываете ли вы addEventListener в чистом TS, defineProps во Vue, useState в React, inject в Angular или $state в Svelte - все эти привычные инструменты построены на перегрузках. Без них сохранить гибкость и строгую типизацию в рамках одной функции было бы просто невозможно. В этой статьи я постараюсь развеять скепсис вокруг этой темы, покажу какие неочевидные преимущества дает перегрузка, даже сейчас в эпоху ИИ. Затем, на практике покажу, как начать использовать перегрузки за три простых шага сведя все сложности к минимуму. Почему так сложно Прежде чем начать, вспомним, почему разработчики так старательно игнорируют этот, на первый взгляд, удобный механизм. Дело в том, что в отличие от действительно строго типизированных языков, в TS перегрузка существует только на уровне типов. Компилятор перекладывает на разработчика всю грязную работу: нужно не только описать варианты вызова функции, но и самостоятельно написать исполняемый код, который вычислит типы аргументов, и какое поведение следует вызвать именно для этого набора аргументов. Такой код невалиден в TypeScript: function whatever(v: string): string { // ... } function whatever(v: number): number { // ... }sourcecraft.dev Для компилятора это две разные функции, которые почему‑то претендуют на одно имя. Поэтому разработчик вынужден перечислить все возможные сигнатуры отдельно, а потом еще и в рантайме объяснять как именно обработать каждый из этих сценариев: function whatever(v: string): string; function whatever(v: number): number; function whatever(v: string | number): string | number { if (typeof v === 'number') { return v; } if (typeof v === 'string') { return v; } return v; }sourcecraft.dev Именно на этом моменте все закатывают глаза и представляют сколько вещей может пойти не так. Не стану защищать TS - это действительно не выглядит чем‑то удобным. Однако, эти сложности можно свести к минимуму полностью сохранив все преимущества, о чем мы поговорим в практической части этой статьи. Но перед этим, чтобы понять для чего все это делать, разберемся с преимуществами перегрузок. От ненависти к любви не один шаг Официальная документация сухо рассказывает о синтаксисе, но никак не объясняет зачем все это нужно, будто это что‑то очевидное. В этой главе на простых примерах, я подробно покажу, чего лишают себя разработчики, отказываясь от перегрузок. Отдельная документация Если вы работаете с единственной функцией с множеством сценариев использования, то вынуждены ограничиться только одним описанием в TSDoc, что потенциально приведет к огромному тексту и чем он больше, тем меньше вероятность, что его прочитают, а если прочитают, то правильно поймут. Функция с перегрузкой может иметь отдельное описание для каждого сценария использования. Возьмем обычную функцию, добавим описание с примерами использования: /** - Сложная функция, которая принимает строку или число и возвращает строку или число. - - @example - // Делай так и получишь строку - whatever('one'); - - // Или делай так и получишь число - whatever(1); */ function whatever(v: string | number): string | number { // ... }sourcecraft.dev Видно, что описание содержит информацию о всех сценариях использования сразу, что в реальных условиях может превратиться в достаточно большой и запутанный текст. Добавим перегрузку и посмотрим как поменялось описание: /** - Простая функция, которая принимает строку и возвращает строку. * - @example - whatever('one'); */ function whatever(v: string): string; /** - Простая функция, которая принимает число и возвращает число. * - @example - whatever(1); */ function whatever(v: number): number; function whatever(v: string | number): string | number { // ... }sourcecraft.dev Обратите внимание, что описание и примеры теперь сосредоточены на разных сценариях использования и поэтому проще передают смысл. Такое описание будет видеть разработчик, наведя курсор в IDE - короткую подсказку, актуальную для сценария, который он хочет использовать в данный момент. Повторное использование В разработке часто возникает необходимость проводить манипуляции с одинаковыми или очень похожими сущностями, но немного разными способами. Давайте разберем пример: function findUserById(id: number): User { // ... } function findUserByName(name: string): User { // ... }sourcecraft.dev Смысл очень похож: в результате мы получим пользователя и скорей всего даже из одного и того же источника данных, но, тем не менее, они живут отдельной жизнью. Позже, стараясь использовать готовые функции, их даже могут объединить в одном месте: const term: number | string = 42; if (typeof term === 'number') { return findUserById(term); } if (typeof term === 'string') { return findUserByName(term); }sourcecraft.dev Обратите внимание, что здесь разработчик был вынужден написать логику принятия решения какой из двух вариантов выбрать и хорошо, что он потрудился найти все варианты использования в проекте, а не написал недостающий способ заново. Это задача, которую помогает решить перегрузка - описать несколько вариантов вызова одной функции и собрать их обработку в одном месте. Теперь, перепишем функцию, но с использованием перегрузки: function findUser(id: number): User; function findUser(name: string): User; function findUser(term: number | string): User { if (typeof term === 'number') { // ... } if (typeof term === 'string') { // ... } throw new Error('Тип аргумента не поддерживается'); }sourcecraft.dev Таким образом, вся логика выбора сценария использования собрана в одном месте, а значит может быть легко переиспользована. К тому же, теперь есть единственная точка расширения и если завтра появится новый способ поиска пользователя, мы знаем где нужно добавить новую реализацию и новые тесты. Управление возвращаемым типом Когда мы работаем с типом аргумента функции, этот тип может сужаться до нужного на лету, но с возвращаемым типом так не происходит, так как никакой связи между ним и аргументами просто нет. В результате, каждый раз при попытке обращения к возвращаемому значению, разработчику придется сужать тип вручную перед тем как что‑то сделать. Представим, что мы хотим создать универсальную функцию для поиска одного и нескольких пользователей: function findUser(ids: number | number[]): User | User[] { // ... }sourcecraft.dev Но при попытке обращения к результату будет ошибка: const user = findUser(1); // user: User | User[] console.log(user.name); console.log(user.length);sourcecraft.dev И это нормально: TS должен быть уверен, что операция завершится успешно, но если ключ может отсутствовать, то компилятор об этом предупредит. При таком подходе разработчики будут вынуждены уточнять тип вручную разными способами: const user = fineUser(1); // user: User | User[] const users = fineUser([1, 2]); // users: User | User[] if ('name' in user) { console.log(user.name); } if (Array.isArray(users)) { console.log(users.length); }sourcecraft.dev На практике это может привести к тому, что проект погрязнет в комбинациях подобных проверок. И хорошо, если проверки вообще будут выполнять: всегда есть соблазн просто отключить их при помощи оператора as. Перегрузка позволяет возвращаемому типу сужаться автоматически в зависимости от аргументов. Давайте посмотрим как все поменяется с перегрузкой: function findUser(id: number): User; function findUser(ids: number[]): User[]; function findUser(ids: number | number[]): User | User[] { // ... } const user = findUser(1); // user: User const users = findUser([1, 2]); // users: User[] console.log(user.name); console.log(users.length);sourcecraft.dev Мы получили ожидаемый тип без каких‑либо дополнительных конструкций: все они спрятаны внутри. Обратная совместимость Есть функции, которые живут в проекте годами. За это время меняются требования, появляются и исчезают сценарии использования и, рано или поздно, возникает необходимость поменять API функции. Но есть проблема - функция уже используется в огромном количестве мест на проекте, а если это библиотека, то и за его пределами. В таким условиях нельзя безопасно поменять набор аргументов и возвращаемых значений, проект просто не скомпилируется. Представим простую функцию, которая изначально принимала один аргумент: function whatever(a: string): void { // ... } whatever('a');sourcecraft.dev Через какое‑то время появляется новый сценарий, для которого этой функции нужен ещё один аргумент: function whatever(a: string, b: string): void { // ... } whatever('a', 'b');sourcecraft.dev Новый вариант прекрасно работает. Но как теперь быть с существующими вызовами о части которых мы даже не можем узнать? Посмотрим, как такую задачу можно решить с помощью перегрузки: // Старый сценарий function whatever(a: string): void; // Новый сценарий function whatever(a: string, b: string): void; // Реализация адаптируется ко всем сценариям function whatever(a: string, b?: string): void { // ... } // Новые сценарии работают как ожидается whatever('a', 'b'); // Старый сценарий продолжает работать whatever('a');sourcecraft.dev Мы просто описали старый сценарий и добавили новый, сразу увидели, что компилятор ругается на то, что реализация обязана соответствовать им всем, и тут же внесли соответствующие изменения в исполняемую функцию. Позже становится понятно, что мы уже не пользуемся старым сценарием и не хотим его больше поддерживать. Явно пометим его как устаревший: /** - @deprecated Эта перегрузка устарела. - Используйте {@link whatever | whatever(a: string)}. */ function whatever(a: number): void;sourcecraft.dev В результате IDE будет отображать эту функцию зачеркнутой при использовании, но объяснит какой именно сценарий следует теперь использовать. Так можно обеспечить плавную миграцию для потребителей. Практика Может показаться, что этот механизм требует титанических усилий и в нем легко запутаться. На самом деле всё гораздо проще. Главное - не пытаться решать все задачи разом, а разбить процесс на несколько простых этапов: Проектирование кейсов Определить, какие варианты использования должна поддерживать функция. Объединение кейсов Описать общую сигнатуру исполняемой функции, которая сможет принять аргументы всех предусмотренных типов. Реализация кейсов Вычислить типы аргументов и написать исполняемый код для каждого кейса. Давайте пройдем каждый этап на простом примере и убедимся, что писать перегрузки - это совсем не страшно. Проектирование кейсов Представим, что мы хотим создать функцию, которая умеет складывать разные типы данных: например, числа, строки, а также числа и строки одновременно. Спроектируем каждый кейс: function concatenate(a: string, b: string): string; function concatenate(a: number, b: number): number; function concatenate(a: string, b: number): string;sourcecraft.dev На данный момент не нужно задумываться над исполняемым кодом, поэтому чтобы этот назойливый компилятор нас не беспокоил раньше времени, просто дадим ему такую заглушку: function concatenate(...args: any[]): any { throw new Error('Заглушка'); }sourcecraft.dev Объединение кейсов Мы определились со сценариями, теперь нужно написать настоящую функцию, типы которой будут совместимы с каждым из сценариев. Для этого нужно взять каждый аргумент по порядку, посмотреть какие типы могут быть переданы в этот аргумент и просто объединить их в union, затем проделать тоже самое с возвращаемым типом. Смотрим на первый, пройдясь по всем кейсам видим, что туда может попасть строка или число, объединим типы: function concatenate(a: string | number): any { throw new Error('Заглушка'); }sourcecraft.dev Компилятор ожидаемо подсказывает, что мы покрыли ещё не все кейсы. Продолжим в том же духе со вторым аргументом: function concatenate(a: string | number, b: string | number): any { throw new Error('Заглушка'); }sourcecraft.dev Осталось сделать тоже самое с возвращаемым типом: function concatenate(a: string | number, b: string | number): string | number { throw new Error('Заглушка'); }sourcecraft.dev Функция теперь совместима с каждым из кейсов, компилятор с облегчением выдыхает. Реализация кейсов К этому моменту мы полностью разобрались с типами, пора убрать заглушку окончательно и приступить к написанию исполняемого кода. Давайте последовательно реализуем каждый кейс. Начнем с первого: function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return ${a} ${b}; } }sourcecraft.dev Поскольку теперь каждый аргумент имеет несколько типов, придётся уточнить их при помощи type guard, что избавит нас от кастинга в блоке if. Позже я расскажу как упростить такие проверки, а пока воспользуемся встроенными средствами. Аналогично реализуем оставшиеся кейсы: function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return ${a} ${b}; } // Кейс 2 if (typeof a === 'number' && typeof b === 'number') { return a + b; } // Кейс 3 if ((typeof a === 'string' && typeof parseFloat(a) === 'number') && typeof b === 'number') { return parseFloat(a) + b; } }sourcecraft.dev Компилятор подсказывает, что не все ветки возвращают значение соответствующее ожидаемому. Для этого явно создадим ошибку в конце: function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return ${a} ${b}; } // Кейс 2 if (typeof a === 'number' && typeof b === 'number') { return a + b; } // Кейс 3 if ((typeof a === 'string' && typeof parseFloat(a) === 'number') && typeof b === 'number') { return parseFloat(a) + b; } throw new Error('Кейс не поддерживается'); }sourcecraft.dev В результате функция компилируется, но даже если во время выполнения в нее попадут аргументы несовместимых типов, разработчик сразу об этом узнает, не погружаясь в отладку. Упрощение Используя перегрузки, мы вынуждены приводить типы аргументов в рантайме - это могут быть очень простые проверки, могут быть сложные, но в любом случае такие конструкции будут повторяться. Для нашего примера отлично подойдет sindresorhus/is, где есть все что нужно для проверки примитивов: import { isString, isNumber, isNumericString } from '@sindresorhus/is'; function concatenate(a: string | number, b: string | number): string | number { if (isString(a) && isString(b)) { return ${a} ${b}; } if (isNumber(a) && isNumber(b)) { return a + b; } // Кейс 3 if (isNumericString(a) && isNumber(b)) { return parseFloat(a) + b; } throw new Error('Кейс не поддерживается'); }sourcecraft.dev Таким образом, написание проверок нам почти ничего не стоило, а код стал читаться намного легче. Для более сложных случаев можно использовать библиотеки для валидации схем, такие как Ow от того же автора или более распространенную Zod. Обзор Теперь, когда мы закончили с реализацией, можем внимательно проанализировать тело функции. Видно, что структура кода следует простому шаблону с одним уровнем вложенности: Проверка 1 Реализация кейса 1 Проверка 2 Реализация кейса 2 Проверка 3 Реализация кейса 3 Выброс ошибки Используя именно такую иерархию, легко сохранить читаемость и управлять сложностью, когда количество кейсов начнет разрастаться. Перегрузка как контракт для ИИ Сегодня всё больше кода пишется ИИ. Не хочу превращать эту главу в очередной спор о том, может ли ИИ заменить разработчика. Намного интереснее другой вопрос: как использовать возможности ИИ так, чтобы получать предсказуемый и качественный результат? Сегодня ИИ прекрасно умеет генерировать код, но у него есть одна особенность: если не задать достаточно чёткие границы, он вполне может предложить несколько разных способов решить одну и ту же задачу, ни один из которых не будет соответствовать архитектуре и API проекта. Именно поэтому, я считаю, в эпоху генерации кода особенно полезны инструменты, которые позволяют формализовать ожидания разработчиков. Мы не пытаемся заставить ИИ писать код вслепую, мы задаём ему четкий контракт, а затем позволяем ему реализовать решение в рамках четко очерченных границ. Получается простая схема: разработчик определяет границы, ИИ реализует поведение, TS проверяет, что результат этим границам соответствует. Поэтому перегрузка может быть полезна не только разработчику, который будет пользоваться функцией, но и ИИ, который будет её реализовывать. В конце концов не так важно чьими руками был написан код, если его поведение соответствует ожиданиям. Заключение Эта статья не ставила перед собой цель стать исчерпывающим руководством по перегрузкам в TypeScript. Для этого пришлось бы гораздо глубже погрузиться в детали, разобрать множество нюансов, что перегрузило бы вместо функций, уже самого читателя.  Вместо этого хотелось показать, что за непривычным синтаксисом скрывается вполне понятный инструмент, который способен решить реальные задачи: сделать API ваших функций удобнее, упростить документацию, сохранить обратную совместимость и даже помочь получать более предсказуемый результат при генерации кода с помощью ИИ. Надеюсь, теперь перегрузка выглядит не как очередная сложная возможность TypeScript, которую можно проигнорировать, а как инструмент, который стоит попробовать в своём проекте. Пишите что думаете, буду рад обратной связи и вопросам.

  9. Your Zod .refine() Never Reaches the Model: What Survives the Trip to JSON Schema - Book: AI That Answers - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Pass a Zod 4 schema to a structured-output helper and every .refine() in it is dropped before the model sees it, with no warning. The OpenAI Node SDK and the Vercel AI SDK both call z.toJSONSchema() on Zod 4 schemas, and that JSON is what the model is constrained by. You can see why in Zod's JSON Schema converter at v4.6.7, the release from 12 October 2026. One line in json-schema-processors.ts decides which of your validation rules get written into the schema: for (const ch of list) contributors[ch._zod.def.check]?.(agg, ch._zod.def);. The contributors map has 13 keys: greater_than, less_than, multiple_of, min_length, string_format and friends. Every .refine() you write is stored as a check named custom (api.ts), and custom is not one of the keys. The optional call finds nothing, and the rule is skipped. Your refinement still runs later, when the response is parsed. The model just never heard about it. A realistic schema with four kinds of rule Here is a refund extraction schema of the kind you would hand to a model that reads support emails. It mixes rules JSON Schema can express with rules it can't. import { z } from "zod"; export const Refund = z .object({ orderId: z.string().regex(/^ORD-\d{6}$/), amount: z.number().positive(), currency: z.enum(["EUR", "USD"]), orderDate: z.iso.date(), refundDate: z.iso.date(), note: z.string().refine((s) => !/https?:\/\//.test(s), { message: "note must not contain links", }), sku: z.string().transform((s) => s.trim().toUpperCase()), }) .refine((r) => r.refundDate >= r.orderDate, { message: "refundDate is before orderDate", path: ["refundDate"], }); Four fields use rules JSON Schema has a keyword for: regex, positive, z.enum and the ISO date format. The rest are the problem: a .refine() on note, a cross-field .refine() on the whole object, and a .transform() on sku. The JSON the model actually gets Below is the shape z.toJSONSchema(Refund, { io: "input" }) produces, which is the mode the Vercel AI SDK uses. I haven't run this exact schema myself. Each property follows the converter's test snapshots in to-json-schema.test.ts and its processor source for the same kinds of Zod call, and I trimmed the long ISO-date regex and the $schema line. Print it on your own version with console.log(JSON.stringify(z.toJSONSchema(Refund, { io: "input" }), null, 2)). { "type": "object", "properties": { "orderId": { "type": "string", "pattern": "^ORD-\\d{6}$" }, "amount": { "type": "number", "exclusiveMinimum": 0 }, "currency": { "type": "string", "enum": ["EUR", "USD"] }, "orderDate": { "type": "string", "format": "date", "pattern": "<long ISO date regex, trimmed>" }, "refundDate": { "type": "string", "format": "date", "pattern": "<long ISO date regex, trimmed>" }, "note": { "type": "string" }, "sku": { "type": "string" } }, "required": [ "orderId", "amount", "currency", "orderDate", "refundDate", "note", "sku" ] } Line it up against the Zod source: | Field | Zod rule | What the model sees | |---|---|---| | orderId | .regex(...) | pattern | | amount | .positive() | exclusiveMinimum: 0 | | currency | z.enum | enum | | orderDate, refundDate | z.iso.date() | format: "date" plus a pattern | | note | .refine(no links) | nothing, just "type": "string" | | sku | .transform(...) | input mode: a plain string; output mode: an error | | the object | .refine(refund after order) | nothing | The test file backs up the note row. It unions z.string().refine((s) => s.length > 2) with z.string().refine((s) => s.length < 9), and the snapshot collapses both branches to a bare "type": "string". They can only dedupe if neither refinement made it into the output. The object-level refinement goes the same way, because .superRefine() and object .refine() also land as custom checks. One more detail from the Zod JSON Schema docs: in input mode, additionalProperties is not set on objects. The AI SDK adds it back with its own addAdditionalPropertiesToJsonSchema pass. In the default output mode, Zod writes additionalProperties: false itself. !A flat illustration of a customs checkpoint where some parcels pass through a gate and others are held back on a table Representable checks cross into JSON Schema. Refinements stay behind. Transforms either throw or tell the model the wrong type Transforms behave differently from refinements, and the behaviour depends on the io option. A .transform() on a string is a pipe: string in, transform out. The pipe processor picks a side with ctx.io === "input" ? (inIsTransform ? def.out : def.in) : def.out (source). In input mode, it takes the string side, so the model sees { "type": "string" } for sku and your uppercasing happens after. That is harmless for this field. It is less harmless for something like z.string().transform(Number), where the model is told only "string", so nothing stops it from writing "twelve" or "12 EUR", and Number() hands your code NaN without a validation error. In output mode, the default, the converter reaches the transform itself. The transform processor calls handleUnrepresentable with "Transforms cannot be represented in JSON Schema", and the docs say Zod throws on unrepresentable types unless you pass unrepresentable: "any". The OpenAI helper calls z4.toJSONSchema(schema, { target: 'draft-7', ... }) without changing io or unrepresentable, so a .transform() inside a schema you pass to zodTextFormat should fail when you build the format, before any request goes out. That is the better failure of the two: loud and early. The provider can strip more Getting a keyword into JSON Schema doesn't guarantee the model is held to it. Each API supports a subset. Anthropic's structured outputs docs list numerical constraints such as minimum, maximum and multipleOf, and string constraints (minLength, maxLength), as unsupported. Their SDK helpers remove those, append each one to the field's description as text, and then validate the response against your original schema. .positive() emits exclusiveMinimum, which the docs don't name but which is a numerical constraint of the same kind. So with zodOutputFormat(), your amount > 0 rule is likely to reach Claude as text in a description rather than as a decoding constraint. Check the transformed schema on your SDK version to confirm. OpenAI's supported subset includes pattern, format, numeric bounds and anyOf, and lists allOf, not and if/then/else as unsupported. It also requires every field to be in required, with optional fields written as a union with null. Unions are their own trap: Zod's union processor emits oneOf for discriminated unions (the source comment says so), and the OpenAI helper's override callback rewrites that oneOf into anyOf before sending. If you call z.toJSONSchema() yourself and send the result to an API directly, you inherit none of those fixes. The fix: describe the rule, validate after, retry once There is no way to make a refinement representable, so the work moves to two places: telling the model the rule in words, and catching the response when it breaks the rule anyway. Start with .describe(): every rule the model has to follow goes there. Zod copies metadata into the generated JSON Schema ("All metadata fields get copied into the resulting JSON Schema", docs), so the description travels with the field. Put .describe() last in the chain. The metadata docs show .meta() returning undefined on a schema created by .refine() from a described one, because metadata belongs to one schema instance and every method returns a new one. Then move transforms out of the schema you send. Keep the wire schema plain and reshape the data after it parses. export const Refund = z .object({ orderId: z .string() .regex(/^ORD-\d{6}$/) .describe("Order id, e.g. ORD-123456"), amount: z.number().positive().describe("Greater than 0"), currency: z.enum(["EUR", "USD"]), orderDate: z.iso.date(), refundDate: z.iso .date() .describe("ISO date, on or after orderDate"), note: z .string() .refine((s) => !/https?:\/\//.test(s), { message: "note must not contain links", }) .describe("Plain text. No URLs."), sku: z.string().describe("SKU exactly as written"), }) .refine((r) => r.refundDate >= r.orderDate, { message: "refundDate is before orderDate", path: ["refundDate"], }); The refinements are still there. They are now the second line of defence instead of the only one. !A flat illustration of a loop where a document passes a checkpoint, gets a correction note attached, and goes around once more Validate the response, send the Zod error back, allow one more pass. Last, validate the response yourself and send the Zod error back for one retry. The OpenAI SDK's responses.parse does call your schema's parse (parseZodObject), but it throws on failure. Calling responses.create and running safeParse yourself gives you the error object to feed back. The model ID below is one of the current ones on OpenAI's models page; swap in yours. import OpenAI from "openai"; import { zodTextFormat } from "openai/helpers/zod"; import { z } from "zod"; const openai = new OpenAI(); type Msg = { role: "user" | "assistant"; content: string }; export async function extract<T extends z.ZodType>( schema: T, name: string, prompt: string, ): Promise<z.infer<T>> { const input: Msg[] = [{ role: "user", content: prompt }]; for (let attempt = 0; attempt < 2; attempt++) { const res = await openai.responses.create({ model: "gpt-6-luna", input, text: { format: zodTextFormat(schema, name) }, }); const raw = res.output_text; const result = schema.safeParse(JSON.parse(raw)); if (result.success) return result.data; input.push( { role: "assistant", content: raw }, { role: "user", content: "That JSON failed validation:\n" + z.prettifyError(result.error) + "\nReturn the corrected JSON only.", }, ); } throw new Error(`${name}: failed validation twice`); } Then reshape after parsing: const refund = await extract(Refund, "refund", emailText); const sku = refund.sku.trim().toUpperCase(); z.prettifyError() turns each issue into a ✖ line with the message, followed by an → at <path> line when the issue has a path. That is close to the sentence a human reviewer would write. The path you set in .refine() is what puts the field name in that message, so set it on every object-level rule. Stop at one retry: if the model fails the same rule twice with the error in front of it, a third attempt is mostly cost. Throw, log the raw output, and look at whether the rule belongs in the prompt or in your code. JSON.parse can also throw on a cut-off or refused response; in production, check the response status before you parse. --- If this was useful The gap between "the schema I wrote" and "the schema the model saw" is one of the first things that bites when you move from a demo to a feature people rely on. AI That Answers is the book in the series that covers this ground in TypeScript: prompts, structured output, validating what comes back, and keeping token cost in view while you do it. ![AI That Answers — AI in TypeScript](amazon.com) The whole AI in TypeScript series, from your first LLM call to agents in production: xgabriel.com

  10. Zod 4.6.6 compromised by malware? github.com/colinhacks/zod/commit/d821685ea6732436… t.co

    138 views

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

2
PackageVersionDownloads · 30dTrend · 30d
zodv4.6.7
v4.6.7
≥590.3M
@zod/miniv4.6.7
v4.6.7
≥50.4K

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
colinhacks/zod mentions in the last 30 days