Rejected shapes
shorn refuses any schema it cannot encode exactly. Unless a section says otherwise, each refusal is an EncodeError thrown when the codec is built: during compile() or the first encode(), not on some later payload.
| Shape | Refused at | Instead |
|---|---|---|
| Union with two branches of one JSON type | build | a const discriminant in every branch |
| Input ≠ output wire shape | build | make both sides agree |
$ref to another document | build | inline the definition |
| Recursion past 256 levels, or with no way out | encode and decode | a nullable back-edge, or an array |
A recursive type inside a Set or Map | build | hold the cycle in an array or an object |
z.undefined(), z.nan(), a symbol, a transform | build | convert at the edge |
ArkType Set or Map | build | a typed collection, or convert at the edge |
| Empty enum | build | none; an enum needs at least one member |
NaN, Infinity or -0 enum member | build | a finite number, or a string |
| Array of zero-width element | build | encode a count instead |
Set of a zero-width element, Map of a zero-width entry | build | encode a count instead |
| A second null or presence marker | build | drop the redundant wrapper |
| No Standard JSON Schema | build | pass structure |
| Async schema on a sync entry point | encode | encodeAsync / decodeAsync |
| Unknown property | encode | close the object, or strip first |
| Uppercase UUID | encode | lowercase it, as RFC 4122 asks |
Non-canonical date-time string | encode | toISOString() it |
Duplicate Set element or Map key | decode | a payload one encoder wrote |
Overlapping unions
Section titled “Overlapping unions”Only nullable, discriminated and type-disjoint JSON Schema unions are currently supported
Three kinds of union are supported. In each one the encoder can name the branch without trying any of them:
- nullable: two branches, one of them
null. One marker byte, no index. - discriminated: one property that is a distinct
constin every branch. See Discriminated unions. - type-disjoint: no two branches share a JSON type, so the type of the value names its branch. See Type-disjoint unions.
What stays refused is a union where two branches could both hold the same value:
z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]); // both objectsz.union([z.int(), z.number()]); // both numbersz.union([z.string(), z.any()]); // any overlaps everythingPicking a branch there would mean trying each in turn and keeping the first that fits. Where two fit, the wrong choice decodes silently into a valid-looking value. Give the branches a discriminant, or give each variant its own codec and pick one by fingerprint.
integer and number count as one type here, because nothing about 5 says which one it was declared as. A branch with a type array, or with no type at all, is refused for the same reason: it overlaps whatever sits next to it.
Extra properties are a separate question from open objects, which are supported. When a validator leaves out additionalProperties entirely, as ArkType and some Valibot object schemas do, the object is closed and has no tail to hold extras. The codec builds, and encoding a value with an extra property throws Unknown object property "x".
Recursion that cannot terminate
Section titled “Recursion that cannot terminate”Recursive value nests deeper than 256
Recursive schemas are supported. Two things about them are still refused.
A cycle needs a way out: a nullable back edge, an optional field, or an array that may be empty. A cycle without one describes no finite value. Rather than prove that at build time, shorn lets the depth counter report it the first time the schema is used.
Depth is capped at 256 levels on both sides. A recursive schema takes its nesting from the payload, and without a cap a couple of bytes per level would buy unbounded stack. Anything deeper wants an array, not a cycle.
Unsupported JSON Schema reference ”…”; only same-document references are supported
A $ref is resolved against the document it appears in. Fetching a remote schema in the middle of a build is not something a serializer should do.
Intersections and never
Section titled “Intersections and never”Unsupported JSON Schema combinator allOf
An intersection (allOf) would need the merged shape, which the validator has not computed. z.never() (not) admits no value, so there is nothing to encode. Merge the intersection yourself into one object schema.
Different input and output shapes
Section titled “Different input and output shapes”Schemas with different input and output wire shapes require a bidirectional codec and are not yet supported
shorn converts and compares both jsonSchema.input() and .output(). A default or a widening refinement that makes the two wire shapes differ is refused, because shorn cannot reverse that change while encoding. With z.codec(), JSON Schema conversion usually throws before this check is reached.
Values with no wire form, and transforms
Section titled “Values with no wire form, and transforms”Date, bigint, Map, Set and date-time strings are supported. What is still refused is a type the wire format has nothing to write for at all: undefined, void, nan, a symbol, a function, a custom type, and a one-way transform.
Zod’s own conversion hook names the type, and shorn keeps that wording:
undefined cannot be represented in JSON Schema
nan, symbol, function, custom, void and transform read the same way, in Zod’s lowercase spelling. A literal gets a line of its own, because with the representability check off Zod would drop an undefined member and write a bigint one as a number:
A literal undefined or bigint cannot be represented in JSON Schema
Where the refusal comes from the validator rather than shorn, the reason is kept and the remedy appended:
<the vendor’s message> (shorn has no wire form for this value; convert it at the edge, see Rejected Shapes)
That is what Valibot’s converter produces for v.undefined() and v.pipe(..., v.transform(...)), and for v.date(), v.bigint(), v.set() and v.map() when the valibotOverride recipe is not used. ArkType reaches it through a constraint shorn has no hook for, such as the predicate behind "string.date".
A one-way transform has no reverse direction in Standard Schema, so shorn cannot undo it on decode. Use z.codec() for a declarative pair that runs both ways, applied outside the codec. See What still needs converting at the edge.
ArkType’s Set and Map
Section titled “ArkType’s Set and Map”ArkType’s Set carries no element type, so there is nothing to encode its members as; convert it at the edge
Set and Map are keywords in ArkType, and neither says what type its members have. A format without type tags writes the elements and nothing else, so there is nothing to write them as. Zod’s z.set(T) and Valibot’s v.set(T) name the element type and are supported.
Recursion through a Set or Map
Section titled “Recursion through a Set or Map”A recursive type inside a Set or Map is not supported; hold the recursion in an array or an object instead
const Node = z.object({ get kids() { return z.set(Node); } });A set’s element is converted as a document of its own, so a $ref inside it would resolve against the wrong document. Put the cycle in an array or an object; both support recursion.
Non-canonical date-time strings
Section titled “Non-canonical date-time strings”Expected a canonical ISO-8601 date-time (the toISOString() spelling), received X
A format: "date-time" string is stored as epoch milliseconds, which hold neither a fractional-digit count nor an offset spelling. Only the one spelling that survives the round trip is accepted. The rest are refused at encode rather than normalized:
"2026-09-03T12:00:00.000Z" // 6 bytes"2026-09-03T12:00:00Z" // refused: no fractional digits"2026-09-03T12:00:00.000+02:00" // refused: an offset"2026-09-03T12:00:00.000000Z" // refused: six fractional digitsSame reason as uppercase UUIDs. Call toISOString() at the edge. A non-string under the same schema is refused earlier, as Expected an ISO-8601 date-time string, received X, where X is its type.
Duplicate Set elements and Map keys
Section titled “Duplicate Set elements and Map keys”Duplicate Set element
Duplicate Map key
Refused on decode. new Set would merge the pair, so the value would re-encode shorter than the payload it came from, and one value would have two encodings. No encoder shorn ships writes such a payload. A hand-made or corrupted one can.
Zero-width Set elements and Map entries
Section titled “Zero-width Set elements and Map entries”Set elements must occupy at least one byte
Map entries must occupy at least one byte
This is the array rule, for the array’s reason: a zero-width element disconnects the count from the input length, so three bytes could declare a million of them. Neither a Set nor a Map has a fixed-count form to exempt, so there is no equivalent of z.array(T).length(n) here. A Map counts its key and value together, so m.map(m.literal("x"), m.string()) is fine and m.map(m.literal("x"), m.literal("y")) is not.
Empty enums, and members with no JSON text
Section titled “Empty enums, and members with no JSON text”Empty enums are unsupported
No valid value means no index to write.
Enum member NaN has no JSON text of its own
An enum whose members are not all strings orders them by their JSON text, because < cannot order mixed types consistently. Four numbers do not survive that: NaN, Infinity and -Infinity all serialize as null, so they would share an index with each other and with a real null member, and -0 serializes and reads back as 0. All four are refused rather than reordered.
Encoding -0 against an enum that does list 0 is refused too, at encode time rather than at build: it would go out as the 0 member’s index and come back as 0. m.literal(0).encode(-0) is refused for the same reason.
The same four values as a single literal are not caught, because the validator’s JSON Schema has already lost them. z.literal(NaN) and both infinities arrive as { type: "number", const: null }, and z.literal(-0) as { const: 0 }.
The first three build a codec that refuses every value it is given and decodes to null. -0 round-trips to 0. Do not use a non-finite number or -0 as a literal.
Arrays of zero-width elements
Section titled “Arrays of zero-width elements”z.array(z.literal("x")); // literal encodes to 0 bytesz.array(z.tuple([]));z.array(z.object({}));An array element must be able to use at least one byte. Otherwise a tiny payload could declare a million elements without providing any element data, and the decoder could not bound the allocation. A tuple may contain zero-width elements because its length comes from the schema.
An array whose count the schema fixes may too, for the same reason, but only up to 1,000,000 slots in total, counted through nesting and through any zero-width object or tuple in between. Past that the same message refuses it. A fixed count needs no payload at all to satisfy, so nothing but the schema can bound it.
z.array(z.literal("x")).length(1_000_000); // fine: a million slotsz.array(z.array(z.literal("x")).length(1000)).length(1000); // refused: a million and oneSee Hostile input.
If you need a count of a constant, encode the count: z.int().nonnegative().
Stacked null or presence markers
Section titled “Stacked null or presence markers”This schema already decodes to null; wrapping it in nullable() would give null two encodings
This schema already decodes to undefined; wrapping it in optional() would give undefined two encodings
m.literal(null).nullable(); // null is already the only valuem.string().optional().nullable().optional(); // undefined would have two spellingscompile(z.string().nullable()).nullable(); // the flag survives compile()Two markers for the same value would make [0] and [1, 0] decode to the same thing, so one value would have two encodings. Repeating one wrapper (x.optional().optional()) is not an error: it collapses and returns the identical object.
Drop the redundant wrapper. Mixing the two once is supported and means something: m.string().optional().nullable() tells absent apart from null.
A validator schema that spells the same thing twice does not hit this error. z.any().nullable(), z.null().nullable() and z.literal(null).nullable() all compile, because compile() sees the inner shape already holds null and drops the wrapper. This error is about a marker you stacked yourself, on an m schema or on a codec compile() already returned.
Missing structural interface
Section titled “Missing structural interface”Standard Schema provides validation but not structure; pass a Standard JSON Schema implementation as the second argument
Valibot always needs this, and so do Zod before 4.2 and ArkType before 2.1.28. Pass the structure argument; see Valibot.
A structure that is neither a Standard JSON Schema implementation nor a JSON Schema document is refused by the same gate:
The second argument must be a Standard JSON Schema implementation (toStandardJsonSchema(schema) for Valibot) or a JSON Schema document
A plain object counts as a document when it has $schema, $ref, type, anyOf, oneOf, const, enum, properties or x-shorn. A validator passed twice, or a structure wrapped in { structure }, has none of those and would otherwise be read as an empty schema and appear to work.
Uppercase UUIDs
Section titled “Uppercase UUIDs”Expected a lowercase UUID, received X
A format: "uuid" string is stored as its 16 bytes, and 16 bytes have no case. Validators accept either spelling (RFC 4122 says to generate lowercase and accept both), so shorn refuses at encode the one spelling that would not survive the round trip. Lowercase at the edge.
A format: "date-time" string is packed too, into the 6 bytes of the instant it names, and for the same reason it accepts only the canonical spelling. Those two are the only string formats shorn packs. Every other one is stored as text.
Async validation on a sync entry point
Section titled “Async validation on a sync entry point”This Standard Schema validates asynchronously; use encodeAsync/decodeAsync, which accept either this schema or a codec built from it.
Pass the same codec to encodeAsync/decodeAsync. fingerprinted() codecs are accepted too. See Validation.