Zod
Zod 4.2 and newer implements both Standard Schema and Standard JSON Schema on the schema object itself. Pass the schema and nothing else.
import { z } from "zod";import { compile, decode, encode, fingerprinted } from "@chichurita/shorn";
const Person = z.object({ name: z.string(), age: z.int().nonnegative(), sex: z.enum(["M", "F", "X"]),});
const person = { name: "Grace", age: 45, sex: "F" } as const;const bytes = encode(Person, person); // 8 bytesconst decoded = decode(Person, bytes);
const PersonWire = compile(Person); // reusable codecconst PersonStored = fingerprinted(compile(Person), { bytes: 4 });Use z.int().nonnegative() wherever a value cannot be negative. shorn then writes an unsigned varint, and 127 takes one byte instead of the two a signed int spends. Supported types maps every Zod shape to its bytes.
Extra properties
Section titled “Extra properties”| Schema | { name: "Grace", extra: true } |
|---|---|
z.object | encodes; Zod strips extra first |
z.strictObject | Zod throws Unrecognized key: "extra" |
z.looseObject | encodes; extras follow the declared fields as a record |
z.object and z.strictObject produce the same bytes and fingerprint, because both emit additionalProperties: false. z.looseObject and z.record are open shapes whose keys go on the wire; see Supported types.
Rich types
Section titled “Rich types”z.date(), z.bigint(), z.set() and z.map() all encode natively. Pass the schema and nothing else:
const Event = z.object({ when: z.date(), // 6 bytes, epoch milliseconds id: z.bigint(), // header byte + the magnitude tags: z.set(z.string()), scores: z.map(z.string(), z.int()),});
const codec = compile(Event);JSON Schema has no keyword for any of the four, so shorn’s conversion hook writes its own x-shorn keyword for them during Zod’s conversion. z.iso.datetime() is packed into the same 6 bytes as a Date, and accepts only the toISOString() spelling.
z.date().nullable() works, and so does a Set of Sets. A recursive type reached through a Set or Map element is refused. A Date cannot be a branch of a type-disjoint union, because it has no JSON type to identify it by.
What is still refused
Section titled “What is still refused”z.undefined(), z.void(), z.symbol(), z.nan(), z.custom(), z.function() and a transform fail at compile, each named by Zod’s own word for it:
undefined cannot be represented in JSON Schemaz.literal(undefined) and a bigint literal get a message of their own, because Zod’s converter would otherwise drop the first and write the second as a number.
For a transform, z.codec() declares both directions in the schema itself, and shorn encodes the wire side:
const Rich = z.object({ slug: z.codec(z.string(), z.string(), { decode: (text) => text.trim(), encode: (text) => text.trim(), }),});
const Wire = z.object({ slug: z.string() });const codec = fingerprinted(compile(Wire));
const bytes = codec.encode(z.encode(Rich, value));const back = z.decode(Rich, codec.decode(bytes));It takes two calls because Standard Schema has no reverse operation, so shorn cannot call z.encode for you without validator-specific code. See Date, BigInt, Map, Set.
Minimum version
Section titled “Minimum version”Zod 4.2 is the minimum. Earlier Zod 4 releases lack Standard JSON Schema, so encode throws provides validation but not structure. On those versions, pass the structure argument from z.toJSONSchema.