Problem
An entity's schema is not its wire schema, and the gap has to be bridged by hand. The bridge is mechanical — the same three differences every time — but there is no derivation for it, so every boundary re-derives it and each one is a chance to get it wrong.
The three differences, from a real migration:
- Nested entities do not render.
z.toJSONSchema cannot walk an entity used as a field, so OpenAPI generation crashes outright.
- Absent is
undefined in the aggregate and null on the wire. The aggregate models an absent value as undefined; the database column and the JSON payload both carry null.
z.optional rejects null. A wire schema derived from .input therefore strips exactly the fields that are nullable in storage, and needs z.nullish instead.
What happened
Each of the three shipped as a distinct bug during one refactor, and the last one is why I am filing this rather than just writing the mapper:
generate:openapi crashed — difference (1), a nested attachments entity
- 16 server integration tests failed — difference (3), the wire schema silently dropping six nullable columns
- a create response omitted
"description": null — difference (2), toStored() not mapping undefined back to null
- an AMQP contract broke silently — the package imported a schema I had deleted,
EventSchema(undefined) reached runtime, payload validation failed, and because publishing is deliberately fire-and-forget the failure had no symptom at all. An integration test simply waited forever for an event that never came
Four failures, four places, one root cause. I ended up hand-writing this:
type StoredShape<T> = {
[K in keyof T]-?: undefined extends T[K] ? Exclude<T[K], undefined> | null : T[K]
}
export const StoredEndOfRentalManagementMissionSchema =
EndOfRentalManagementMissionEntity.input.extend({
attachments: z.array(EndOfRentalManagementMissionAttachmentSchema), // entity -> record
description: z.nullish(DescriptionSchema), // optional -> nullish
effectiveAt: z.nullish(InstantSchema),
receivedAt: z.nullish(InstantSchema),
reason: z.nullish(EndOfRentalManagementMissionReasonEnum),
lostToBrand: z.nullish(LostToBrandSchema),
errorReason: z.nullish(ErrorReasonSchema),
})
Plus a toStored() doing ?? null on the same six fields. Every line of that is derivable from the entity definition.
Proposal
A projection deriving the transport shape from the entity:
Entity.wire // nested entities flattened to records, optional -> nullish
Entity.wire.parse(payload) // accepts null where the aggregate has undefined
entity.toWire() // undefined -> null on the way out
Even the narrow version would remove most of the pain: make z.toJSONSchema work on a schema containing nested entities. That single fix unblocks OpenAPI and ts-rest, which is what most consumers hit first.
Null-vs-undefined policy wants to be configurable rather than assumed — some stores use null, some omit the key — but the default for a JSON boundary is clear enough to ship.
Related
Found while migrating a production real-estate service to 0.7.0 (zod 4.3.6).
Problem
An entity's schema is not its wire schema, and the gap has to be bridged by hand. The bridge is mechanical — the same three differences every time — but there is no derivation for it, so every boundary re-derives it and each one is a chance to get it wrong.
The three differences, from a real migration:
z.toJSONSchemacannot walk an entity used as a field, so OpenAPI generation crashes outright.undefinedin the aggregate andnullon the wire. The aggregate models an absent value asundefined; the database column and the JSON payload both carrynull.z.optionalrejectsnull. A wire schema derived from.inputtherefore strips exactly the fields that are nullable in storage, and needsz.nullishinstead.What happened
Each of the three shipped as a distinct bug during one refactor, and the last one is why I am filing this rather than just writing the mapper:
generate:openapicrashed — difference (1), a nestedattachmentsentity"description": null— difference (2),toStored()not mappingundefinedback tonullEventSchema(undefined)reached runtime, payload validation failed, and because publishing is deliberately fire-and-forget the failure had no symptom at all. An integration test simply waited forever for an event that never cameFour failures, four places, one root cause. I ended up hand-writing this:
Plus a
toStored()doing?? nullon the same six fields. Every line of that is derivable from the entity definition.Proposal
A projection deriving the transport shape from the entity:
Even the narrow version would remove most of the pain: make
z.toJSONSchemawork on a schema containing nested entities. That single fix unblocks OpenAPI and ts-rest, which is what most consumers hit first.Null-vs-undefined policy wants to be configurable rather than assumed — some stores use
null, some omit the key — but the default for a JSON boundary is clear enough to ship.Related
z.toJSONSchemafailure in particular is independent of any database.Found while migrating a production real-estate service to 0.7.0 (zod 4.3.6).