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(), role: z.enum(["viewer", "editor", "admin"]),});
const person = { name: "Grace", age: 45, role: "admin" } 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.
Defaults
Section titled “Defaults”.default() compiles to an optional field. Zod describes a defaulted field as optional on the input side and required on the output side, and shorn takes the input side’s optionality. Valibot’s v.optional(T, x) and ArkType’s "string = 'x'" compile to the same shape, so all three produce the same bytes and fingerprint. .prefault() compiles the same way.
The field costs one bit in the object’s presence bitmap. Encode validates first, so the value always carries the field and the bit is always set. A payload with the bit clear still decodes, with the default filled in, because decode validates too. unchecked() runs no validator, so under it a missing field goes out missing and comes back missing.
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. Compile the wire side rather than the codec: passed to compile(), a z.codec() is refused, because shorn validates on both sides and decode would run its transform a second time.
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.