Skip to content

Expose an HTTP contract

Problem: you have an entity and need request and response schemas for a route, converted to JSON Schema, without hand-writing omit lists that drift from the model.

Snippets below assume these imports:

ts
import { z } from "zod";
import { P } from "unthrown";
import { Entity } from "@btravstack/entity";

Use the four ZodObject members directly

ts
const CreateBody = Organization.createInput; // input minus generated
const UpdateBody = Organization.updateInput; // output minus immutable and computed, partial
const ResponseBody = Organization.output; // stored state

Nothing to maintain: createInput drops whatever generated names, and updateInput drops whatever immutable names plus the computed fields. Add a generated field to the entity and the create body follows.

Convert to JSON Schema

All four convert in both directions:

ts
import { ZodToJsonSchemaConverter } from "@orpc/zod";

const converter = new ZodToJsonSchemaConverter();
const [createSchema] = converter.convert(Organization.createInput, "input");
const [responseSchema] = converter.convert(Organization.output, "output");

Or with zod directly:

ts
z.toJSONSchema(Organization.output, { io: "output" }); // ✓
z.toJSONSchema(Organization.createInput, { io: "input" }); // ✓

Do not hand the class to a converter

ts
z.toJSONSchema(Organization, { io: "output" }); // ✗ throws

The class carries a .transform() — it parses to an instance, not to plain data — and a transforming schema has no output representation. That is deliberate, and it is the reason the four plain ZodObjects exist separately.

Rule of thumb: contracts compose the four ZodObjects; domain code composes the class.

Derive further views

They are ordinary ZodObjects, so the usual combinators work:

ts
const Summary = Organization.output.pick({ id: true, slug: true });
const Listing = z.object({
  items: z.array(Organization.output),
  total: z.number(),
});

Handle failures at the edge

Issues are structured, so a field-keyed error response is a lookup rather than a string parse. Entity.keysOf normalises an issue's path to plain keys — Standard Schema permits a segment to be a bare key or a { key } wrapper, and the helper absorbs both — and Entity.renderIssue is the human spelling of one issue, the same one InvalidEntity.message is built from:

ts
const result = Organization.make(await request.json());

return result.match({
  ok: (org) => json(200, org.toJSON()),
  errCases: (m) =>
    m.with(P.tag("InvalidEntity"), (e) =>
      json(422, {
        errors: e.issues.map((i) => ({
          field: Entity.keysOf(i).join("."), // "" for a whole-entity rule
          message: i.message,
        })),
      }),
    ),
  defect: (cause) => {
    report(cause);
    return json(500, { error: "internal" });
  },
});

When the response is a flat list of strings rather than field-keyed objects, e.issues.map(Entity.renderIssue) is the whole mapping — "slug: Too small: …" per issue, path prefix included.

An issue with an empty path came from invariants — a rule spanning the whole entity rather than one field. That distinction is what lets you decide whether to attach the message to a form field or to the form.

A union as a request body

Entity.union gives a discriminated union with one branch per member, so a polymorphic endpoint keeps its contract:

ts
const Member = Entity.union("kind", [User, ServiceAccount]);

const Body = Member.input; // z.discriminatedUnion("kind", [...])
z.toJSONSchema(Body, { io: "input" }); // one branch per member

Released under the MIT License.