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:
tsimport { z } from "zod"; import { P } from "unthrown"; import { Entity } from "@btravstack/entity";
Use the four ZodObject members directly
const CreateBody = Organization.createInput; // input minus generated
const UpdateBody = Organization.updateInput; // output minus immutable and computed, partial
const ResponseBody = Organization.output; // stored stateNothing 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:
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:
z.toJSONSchema(Organization.output, { io: "output" }); // ✓
z.toJSONSchema(Organization.createInput, { io: "input" }); // ✓Do not hand the class to a converter
z.toJSONSchema(Organization, { io: "output" }); // ✗ throwsThe 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:
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:
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:
const Member = Entity.union("kind", [User, ServiceAccount]);
const Body = Member.input; // z.discriminatedUnion("kind", [...])
z.toJSONSchema(Body, { io: "input" }); // one branch per member