Skip to content

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 bytes
const decoded = decode(Person, bytes);
const PersonWire = compile(Person); // reusable codec
const 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.

Schema{ name: "Grace", extra: true }
z.objectencodes; Zod strips extra first
z.strictObjectZod throws Unrecognized key: "extra"
z.looseObjectencodes; 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.

.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.

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.

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 Schema

z.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.

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.