Skip to content

Supported types

shorn supports the shapes that sit in two sets at once: what JSON Schema can describe, and what a format without type tags can encode. Everything else is on Rejected shapes.

ShapeZodValibotArkTypeBytes
Stringz.string()v.string()"string"varint length + UTF-8
Booleanz.boolean()v.boolean()"boolean"1
Signed intz.int()v.pipe(v.number(), v.integer())"number.integer"ZigZag varint
Unsigned intz.int().nonnegative()+ v.minValue(0)"number.integer >= 0"varint
Floatz.number()v.number()"number"8
Literalz.literal(v)v.literal(v)"'M'"0
Nullz.null()v.null()"null"0
Enumz.enum([...])v.picklist([...])"'M' | 'F'"varint index
UUIDz.uuid()v.pipe(v.string(), v.uuid())"string.uuid"16
Datez.date()v.date()"Date"6 for any current date
date-time stringz.iso.datetime()v.pipe(v.string(), v.isoTimestamp())none6
BigIntz.bigint()v.bigint()"bigint"1 + one per magnitude byte

A varint is a variable-length integer: small values take one byte, larger values take more. ZigZag is the trick that lets a varint hold negative numbers, by interleaving them with the positives (0, -1, 1, -2, 2 become 0, 1, 2, 3, 4). It doubles the magnitude, so a signed int needs an extra byte at half the value a uint would. Declare an integer non-negative whenever it is.

Enum members do not have to be strings. z.enum({ Ok: 200, Missing: 404 }) writes a one-byte index instead of the eight bytes each number would otherwise cost. Members are indexed in canonical order: by value for an all-string enum, and by JSON text for anything else, because < cannot order mixed types consistently.

A uuid is stored as the 16 bytes it stands for, not the 36 characters it is written as. Bytes have no case, so shorn accepts lowercase UUIDs only. An uppercase one is refused rather than returned as a different string than the one you gave it. RFC 4122 says to generate lowercase.

uuid and date-time are the only two string formats shorn packs. Every other format is stored as text. A date-time becomes the 6 bytes of the instant it names.

Epoch milliseconds cannot remember how many fractional digits the string had or how its offset was written, so only the toISOString() spelling is accepted and every other spelling is refused. ArkType has no format: "date-time" spelling: its "string.date.iso" converts to a pattern, so it stays an ordinary string.

A Date is those same 6 bytes, decoded back to a Date. A bigint is a varint header holding the byte count and sign of the magnitude, followed by the magnitude itself in little-endian order, so it has no practical width limit. See Date, BigInt, Map, Set for the spellings in each validator, the Valibot recipe, and the x-shorn keyword behind all four.

ShapeZodValibotArkTypeBytes
Arrayz.array(T)v.array(T)"T[]"varint count + elements
Fixed arrayz.array(T).length(n)v.pipe(v.array(T), v.length(n))"T[] == n"elements only
Tuplez.tuple([...])v.tuple([...])["string", "number"]elements only
Tuple with restz.tuple([...], T)v.tupleWithRest([...], T)["string", "...", "T[]"]fixed items + varint count + rest
Setz.set(T)v.set(T)refusedvarint count + elements
Mapz.map(K, V)v.map(K, V)refusedvarint count + key/value pairs

An array writes its element count on the wire. A tuple gets its count from the schema. That difference is why a tuple may contain zero-width elements and an array may not.

A Set writes exactly what an array of the same elements writes, and a Map exactly what an array of [key, value] tuples writes, both in iteration order. They differ in what they decode to and in their fingerprint, so a payload written as one is never read back as the other. Neither may hold a zero-width element, and the decoder refuses a duplicate element or key instead of silently merging it. ArkType’s Set and Map keywords carry no element type and are refused.

An array whose minItems equals its maxItems is a third case. The schema fixes the count, so the count is not written and the element may be zero-width, as in a tuple. The count is still checked against the remaining input before anything is allocated.

When the element is zero-width there is no input to check against, so instead the total number of slots such a schema can fill from an empty payload is capped at 1,000,000 across all nesting. See Hostile input.

z.discriminatedUnion("kind", [...]) → a varint branch index, then that branch.

Every branch must be an object with one property that is a distinct const in each branch. That property, the discriminant, costs nothing on the wire: it is a literal inside its branch, and literals encode to zero bytes. So the index is the only byte added, and it replaces the field a self-describing format would write out in full.

Branches are ordered by discriminant value, so declaration order does not reach the wire. Adding a branch shifts the indices of the branches that sort after it. See Schema changes.

z.union([z.string(), z.number()]) → the same varint branch index, then that branch.

A union needs no discriminant when no two branches share a JSON type, because then the type of the value already tells you which branch it belongs to. Nothing is tried and nothing is guessed. Exactly one branch can hold a given value, so the encoder reads typeof and writes the index.

z.union([z.string(), z.number()]); // number is 0, string is 1
z.union([z.string(), z.array(z.string()), z.null()]);
z.union([z.literal("a"), z.literal(3)]); // the index is the whole payload

The seven JSON types are string, number, boolean, null, array, object, and integer, which folds into number. Branches are ordered by type name, so again declaration order does not reach the wire.

Two branches that share a type stay refused. Nothing about 5 says whether it was declared as z.int() or z.number(), and choosing between two object branches without a const would mean trying each one in turn.

z.lazy(() => Node), or a self-referencing getter → the same shapes, with the cycle read again at each level.

const Node = z.object({
value: z.string(),
get children() {
return z.array(Node);
},
});

A recursive schema adds nothing to the wire. The cycle lives in the schema, so a tree costs exactly what its levels would cost written out by hand: the array count at each level, and the fields of each node.

Recursion needs a way to stop, and that exit is what keeps the payload finite: a nullable back edge, an optional field, or an array that can be empty. A cycle without one, such as { next: Node } with no null and no ?, describes no finite value, and reports a depth error the first time it is used.

Nesting is capped at 256 levels on both sides, because here the depth comes from the payload rather than the schema. A linked list longer than that wants an array. Recursion is for trees.

A definition that is reached twice but never through itself is not recursive. It is inlined, and encodes and fingerprints exactly as it would written out in full.

The cycle has to pass through an array or an object. A recursive type reached through a Set or Map element is refused, because that element is converted as a document of its own, and a reference inside it would resolve against the wrong document.

A nullable marker over a recursive definition that already admits null is dropped rather than doubled, the same rule every other shape follows.

Recursion works with both union forms, which is what a general JSON value needs:

const Json = z.union([
z.string(), z.number(), z.boolean(), z.null(),
z.array(z.lazy(() => Json)),
z.record(z.string(), z.lazy(() => Json)),
]);

Six branches, no two sharing a type, two of them recursive. A branch that is the whole definition works too. Its type is read at the far end of the $ref.

ShapeZodBytes
Recordz.record(z.string(), T)varint count + key/value pairs
Open objectz.looseObject({...}), .catchall(T)declared fields + a record of the rest
Dynamicz.any(), z.unknown()1 tag byte + the value

These are the shapes whose contents the schema does not know in advance, so they are the only ones that write something a closed schema never does. A record writes its keys. A dynamic value writes a one-byte type tag. You pay for both only where you use them.

z.object({ id: z.uuid(), attributes: z.record(z.string(), z.string()) });
z.object({ id: z.uuid(), payload: z.any() });

A record’s keys go on the wire in ascending UTF-16 code unit order, and the decoder refuses any other order, which also rules out a repeated key. A dynamic value can hold null, a boolean, a number, a string, an array, or a plain object, nested up to 64 levels. A Date or a Map under z.any() is refused rather than written as the empty object it looks like. See Byte layout for the tag table.

An open object combines the two. The declared fields are written bare in their positional slots, then everything else follows as a record.

z.looseObject leaves the type of the extras open, so their values carry a tag. .catchall(T) declares the type, so only their keys are on the wire. An open object costs one byte even when it has no extras. A closed object costs nothing. A key in the tail that repeats a declared field is refused on decode, because it would overwrite a field decoded a moment earlier.

ShapeZodValibotArkType
Closedz.object({...})v.object({...})type({...})
Strictz.strictObject({...})v.strictObject({...})"+": "reject"
Optional fieldz.optional(T)v.optional(T)"key?": "string"

An object writes a presence bitmap for its optional fields, ceil(n / 8) bytes, left out entirely when there are none. Then come the values in canonical key order. Field names are never written. The validator and shorn may handle extra properties differently; see Zod, Valibot, and ArkType.

z.nullable(T) · v.nullable(T) · "T | null" → one marker byte, then the value if there is one. Both JSON Schema spellings work: an anyOf with two branches where one is null, and a type array with two entries where one is "null". A two-branch nullable is the cheapest union there is: one byte and no index.

Objects, arrays, and tuples nest with no per-level header. A nested object encodes as nothing more than its fields. Nesting fixed by the schema has no limit of its own, though at about 1,400 levels through compile(), or 1,600 through m, JavaScript throws a RangeError while the codec is being built. Reaching that takes a hostile schema, not merely hostile bytes. Nesting chosen by the payload is capped: 256 levels for a recursive schema, 64 for a dynamic value.

.min(), .max(), .regex(), .email(), and .refine() run during encode and decode, but they do not change the wire format. Adding .max(300) does not change the fingerprint.

Four refinements are exceptions, because each one removes something the payload would otherwise have to carry. All four change both the bytes and the fingerprint:

RefinementJSON SchemaEffect
.nonnegative() on an integerminimum >= 0unsigned varint instead of ZigZag
.length(n) on an arrayminItems === maxItemsthe count is dropped
.uuid() on a stringformat: "uuid"16 bytes instead of 36 characters
z.iso.datetime() on a stringformat: "date-time"6 bytes instead of about 25 characters

These have no JSON Schema form, so no validator can select them. They are reachable only through the m API:

ShapeBuilderBytes
Raw bytesm.bytes()varint length + contents
32-bit floatm.float32()4

m.date(), m.bigint(), m.set() and m.map() are not on this list. They have a JSON Schema form through shorn’s own x-shorn keyword, so a validator does select them. See Date, BigInt, Map, Set. There is no m builder for a date-time string, which compile() reaches through the format alone.