One declaration, four schemas
input, output, createInput and updateInput are derived from one field map plus generated / immutable / computed. Plain ZodObjects, so they convert to JSON Schema in both directions — no hand-written omit lists.
One declaration yields a type, four request/response schemas, behaviour, and a class that is itself a zod schema — with branded fields, immutable data, sealed construction, and Result instead of throws.
import { z } from "zod";
import { Entity } from "@btravstack/entity";
const OrgId = z.uuid().brand("OrgId");
const Slug = z.string().min(1).brand("Slug");
const DisplayName = z.string().min(1).brand("DisplayName");
const Instant = z.iso.datetime().brand("Instant");
const Upper = z.string().min(1).brand("Upper");
class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
},
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
],
},
) {
get greeting(): string {
return `Welcome, ${this.name}`;
}
}
// The package reads no clock and generates no id: bind the sources once,
// where your ports already live.
const createOrganization = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});
const org = createOrganization({ slug, name }).getOrThrow();
await db.insert(org.toJSON()); // exactly the stored shape — never `_tag`
const loaded = Organization.make(row).getOrThrow(); // rows, imports, event folds
const renamed = loaded.update({ name: next }).getOrThrow(); // a NEW entityFailures are values, not exceptions:
import { P } from "unthrown";
Organization.make({ ...row, name: "" }).match({
ok: (o) => o,
errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), // [{ path: ["name"], … }]
defect: (cause) => report(cause), // a bug in domain code, kept separate
});The design rule the whole package turns on: contracts compose the four plain ZodObjects; domain code composes the class itself. Why.