Skip to content

No wire projection: nested entities break z.toJSONSchema, and undefined/null is bridged by hand at every boundary #72

Description

@btravers

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:

  1. Nested entities do not render. z.toJSONSchema cannot walk an entity used as a field, so OpenAPI generation crashes outright.
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions