diff --git a/.changeset/entity-is-a-schema.md b/.changeset/entity-is-a-schema.md new file mode 100644 index 0000000..7c4863b --- /dev/null +++ b/.changeset/entity-is-a-schema.md @@ -0,0 +1,30 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: `instance` is removed — the entity class is now itself a zod +schema. + +```ts +// before +class Order extends Entity("Order")({ customer: Customer.instance }) {} +z.object({ owner: Organization.instance }); +fromSchema(Organization.instance); + +// after +class Order extends Entity("Order")({ customer: Customer }) {} +z.object({ owner: Organization }); +fromSchema(Organization); +``` + +The class carries zod's internal slots (`_zod`, `~standard`) but **not** its +methods. That is deliberate: the full `ZodType` surface would put a throwing +`.parse()` on every entity beside the `make` that returns a `Result`. Use +`make` to parse, and zod's function forms to wrap — `z.optional(Organization)` +rather than `Organization.optional()`. + +`Entity.union(...)` is a schema on the same terms, so a union composes and +nests identically. + +Migration: delete `.instance`. `z.object({ owner: Organization })` now works, +which it never did before. diff --git a/CLAUDE.md b/CLAUDE.md index 3657ad4..0a37d12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `@btravstack/entity` — a domain-entity builder on zod v4. One declaration (`class X extends Entity("X")(fields, options)`) yields a type, four plain -`ZodObject` validators, behaviour, and a composable `instance` schema. Every +`ZodObject` validators, behaviour, and a class that is itself a zod schema. Every fallible operation returns an `unthrown` `Result` instead of throwing. @@ -66,12 +66,11 @@ Eight source modules under `packages/entity/src`, split by what they own: `Sealed`, the module-private `unique symbol` that makes `new X(...)` a compile error. Written independently of the builder's body-local values so `EntityStatic` can serve as the builder's explicit return annotation. -- **`instance.ts`** — `attachInstance` installs `instance` as a lazy, - self-overwriting accessor getter. The getter (not a plain value) is what - makes `X.instance.parse(...)` build an `X` rather than the base class, since - the subclass does not exist when the builder runs. `instance` is a zod - schema, so it is already a Standard Schema; the class carries no `~standard` - of its own. +- **`schema.ts`** — `attachSchema` makes the entity class itself a zod + schema by delegating `_zod` and `~standard` to a lazily built, per-receiver + transform. Only those two slots, never the full `ZodType`: the methods would + put a throwing `.parse()` beside `make`. Reading from the receiver is what + makes a schema built from a subclass yield that subclass. - **`union.ts`** — `Entity.union(discriminant, members)`. Dispatches on the declared discriminant rather than trying each branch, so a failing member reports its own issues. @@ -86,8 +85,8 @@ Eight source modules under `packages/entity/src`, split by what they own: construction path, so they cannot drift from their sources. The design rule the whole package turns on: **contracts compose the four plain -`ZodObject`s; domain code composes `instance`.** `instance` carries a -`.transform()`, so `z.toJSONSchema(instance, { io: "output" })` throws by +`ZodObject`s; domain code composes the class itself.** The class carries a +`.transform()`, so `z.toJSONSchema(SomeEntity, { io: "output" })` throws by design — `contract.spec.ts` pins that both ways. ## Binding conventions diff --git a/README.md b/README.md index e516b56..073b884 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,11 @@ That one declaration gives you: branded (nominal) type; - **validators** — `Organization.input` / `.output` / `.createInput` / `.updateInput`, four plain `ZodObject`s a contract layer can hand straight - to a JSON Schema converter, plus `Organization.instance` for decoding - straight to a class instance; + to a JSON Schema converter, while the class itself is a zod schema that + parses straight to an instance; - **behaviour** — the class body (`greeting` above) plus built-in `update`/`encode`/`toJSON`/`equals`; -- **composability** — `Organization.instance` is a field like any other, so an +- **composability** — `Organization` is a field like any other, so an aggregate is itself an entity rather than a bare schema, and the entities inside it keep their behaviour. @@ -124,27 +124,28 @@ Organization.make(rawJson); `new Organization(...)` does not compile — construction is **sealed**; see [Sealed construction](#sealed-construction). -## The five schema members +## The four schema members, and the class itself ```ts Organization.input; // ZodObject — everything make() accepts -Organization.output; // ZodObject — stored state and response body; make() accepts +Organization.output; // ZodObject — stored state and response body Organization.createInput; // ZodObject — input minus generated Organization.updateInput; // ZodObject — output minus immutable, partial -Organization.instance; // ZodType — parses to a class instance + +Organization; // …is itself a zod schema, parsing to a class instance ``` -**Contracts compose the four `ZodObject`s. Domain code composes `instance`.** +**Contracts compose the four `ZodObject`s. Domain code composes the class.** This is the rule the whole design turns on, and it comes from a real constraint in zod's schema-to-JSON-Schema conversion: A schema that carries a `.transform()` — which is what turns parsed data into -a class instance — **has no output representation**. `instance` does exactly +a class instance — **has no output representation**. The class does exactly that (it parses to `Organization`, not to plain data), so: ```ts z.toJSONSchema(Organization.output, { io: "output" }); // ✓ real JSON Schema -z.toJSONSchema(Organization.instance, { io: "output" }); // ✗ throws — by design +z.toJSONSchema(Organization, { io: "output" }); // ✗ throws — by design ``` The four plain `ZodObject`s (`input`, `output`, `createInput`, @@ -158,7 +159,7 @@ const UpdateBody = Organization.updateInput; const ResponseBody = Organization.output; ``` -`instance` is the composable surface for domain code — the only member that +The class is the composable surface for domain code — the only one that produces real class instances. It is a valid **field**, so an aggregate is an entity in its own right, with the invariants, immutability and entry points that implies: @@ -166,8 +167,8 @@ that implies: ```ts class Order extends Entity("Order")({ id: OrderId, - customer: Customer.instance, - watchers: z.array(Customer.instance), + customer: Customer, + watchers: z.array(Customer), }) {} const order = Order.make(row).getOrThrow(); @@ -178,27 +179,39 @@ order.customer._tag; // and its tag, for `P.tag(...)` matching An invariant can span the outer entity and a nested one, a nested failure reports the full path (`["customer", "name"]`), and `JSON.stringify` walks the -whole tree down to plain data. It still composes into a plain `z.object(...)` -too, when what you want is a schema rather than an entity: +whole tree down to plain data. + +The same class composes anywhere zod takes a schema: ```ts -const Aggregate = z.object({ organization: Organization.instance }); -Aggregate.parse(raw).organization instanceof Organization; // true +z.object({ organization: Organization }); +z.array(Organization); +z.optional(Organization); // the function forms — see below ``` -`instance` is also a [Standard Schema](https://standardschema.dev), being a -zod schema, so it hands straight to anything that accepts one — a router, a -form library, `@unthrown/standard-schema`'s `fromSchema`: +and, being a zod schema, it is a [Standard Schema](https://standardschema.dev), +so it hands straight to a router, a form library, or +`@unthrown/standard-schema`'s `fromSchema`: ```ts import { fromSchema } from "@unthrown/standard-schema"; -const parseOrg = fromSchema(Organization.instance); +const parseOrg = fromSchema(Organization); parseOrg(raw).getOrThrow(); // Organization ``` -It does **not** make `z.object({ owner: Organization })` work — zod requires -a real `ZodType`, so nesting always goes through `Organization.instance`. +**The class carries zod's slots, not zod's methods.** That is deliberate: +inheriting the full `ZodType` surface would put a throwing `.parse()` on every +entity, beside the `make` that returns a `Result` — the exact thing this +package exists to avoid. So use `make` to parse directly, and zod's _function_ +forms to wrap: + +```ts +Organization.make(raw); // ✓ Result +z.optional(Organization); // ✓ +Organization.parse(raw); // ✗ does not exist +Organization.optional(); // ✗ does not exist +``` ## The three entry points @@ -239,8 +252,8 @@ never send them. function. Rehydrating a database row and validating an untrusted import differ in where the data came from, not in what has to happen to it — parse against `input`, re-derive the computed fields, check the invariants, construct. A -second name for that would be an alias, so there is one: `make`. `instance` -runs it under the hood, which is why nesting works. +second name for that would be an alias, so there is one: `make`. The class as +a schema runs it under the hood, which is why nesting works. `update` returns a **new** entity — data is immutable — and re-runs `invariants`, so a patch where every individual field is valid but the @@ -452,7 +465,7 @@ const Member = Entity.union("kind", [User, ServiceAccount]); Member.make(row).getOrThrow(); // User | ServiceAccount — the real class Member.input; // discriminated union, one branch per member Member.output; // ditto — JSON Schema in both directions -Member.instance; // parses to the member class; nests like any other +Member; // …is itself a schema; parses to the member class, nests like any other Member.members; // the tuple, for registries and exhaustiveness ``` @@ -464,7 +477,7 @@ discriminant names the key and lists what was expected. A union is a valid field too, so an aggregate can hold one: ```ts -class Audit extends Entity("Audit")({ id: AuditId, actor: Member.instance }) {} +class Audit extends Entity("Audit")({ id: AuditId, actor: Member }) {} ``` **Why the discriminant is a declared field and not the tag.** `_tag` is @@ -507,7 +520,7 @@ class InvalidEntity extends TaggedError("InvalidEntity")<{ | schema validation (a field fails its own zod check) | `InvalidEntity`, issue has a `path` | bad input, expected | | a broken `invariants` rule | `InvalidEntity`, issue has no `path` | bad input, expected — the rule spans the entity, not one field | | `add`'s output failing its own declared schema | **defect** | `add` is pure, total, and typed — a violation is a bug in domain code, not bad caller input | -| any of the above, reached through `instance` | zod issues, paths composed | a nested field failure reports the full path | +| any of the above, reached through the class | zod issues, paths composed | a nested field failure reports the full path | `issues` is carried **structured**, exactly as the validator produced it, not rendered into prose — so keying a field-level error response is a `path` @@ -528,16 +541,16 @@ Trial.make(brokenRow); // issues: [{ message: "trialEndsAt must be after createdAt" }], // an invariant: no path // }) -// through `instance`, paths compose with the position of the nested entity: -z.object({ owner: Organization.instance }).safeParse({ owner: { slug: "" } }); +// nested, paths compose with the position of the nested entity: +z.object({ owner: Organization }).safeParse({ owner: { slug: "" } }); // issues: [{ path: ["owner", "slug"], message: "Too small: …" }] -z.object({ owner: Organization.instance }).safeParse({ owner: brokenRow }); +z.object({ owner: Organization }).safeParse({ owner: brokenRow }); // issues: [{ path: ["owner"], message: "trialEndsAt must be after createdAt" }] ``` A `Defect` — the unexpected-failure channel `unthrown`'s `Result` reserves separately from `E` — is never folded into an ordinary validation issue, even -through `instance`: an unmodelled bug in domain code stays distinguishable +when nested: an unmodelled bug in domain code stays distinguishable from bad caller input all the way to the edge. ## Sealed construction @@ -725,7 +738,7 @@ and real `Result`s built from _your_ copies of those packages — if this package pinned its own instead, a consumer would end up with two copies of `zod` (or `unthrown`) in the dependency tree, and identity checks like `result instanceof Result`, `schema instanceof z.ZodType`, or composing this -package's `instance` schema into the consumer's own `z.object({...})` can +package's entity class into the consumer's own `z.object({...})` can silently misbehave across the module boundary between two copies of the same package. diff --git a/packages/entity/README.md b/packages/entity/README.md index 265f651..d97d64e 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -62,23 +62,23 @@ class name it labels, ahead of the field map. ## Statics -| Static | Kind | Purpose | -| -------------------- | ----------- | --------------------------------------------------------------------------------- | -| `entityName` | `string` | the tag passed to `Entity(tag)` | -| `input` | `ZodObject` | the full wire object | -| `output` | `ZodObject` | stored state and response body | -| `createInput` | `ZodObject` | create request — `input` minus `generated` | -| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | -| `instance` | `ZodType` | parses straight to a class instance; valid as a field, so aggregates are entities | -| `make(state)` | method | already-stored state → entity, for row mappers and event folds | -| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | -| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | +| Static | Kind | Purpose | +| -------------------- | ----------- | ---------------------------------------------------------------------------- | +| `entityName` | `string` | the tag passed to `Entity(tag)` | +| `input` | `ZodObject` | the full wire object | +| `output` | `ZodObject` | stored state and response body | +| `createInput` | `ZodObject` | create request — `input` minus `generated` | +| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | +| _the class itself_ | zod schema | parses to a class instance; valid as a field and anywhere zod takes a schema | +| `make(state)` | method | already-stored state → entity, for row mappers and event folds | +| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | +| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | **Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`, -`updateInput`); domain code composes `instance`.** All four `ZodObject`s -generate JSON Schema in both `"input"` and `"output"` directions. `instance` +`updateInput`); domain code composes the class itself.** All four `ZodObject`s +generate JSON Schema in both `"input"` and `"output"` directions. The class carries a transform, so it has no _output_ representation — -`z.toJSONSchema(SomeEntity.instance, { io: "output" })` throws by design, and a +`z.toJSONSchema(SomeEntity, { io: "output" })` throws by design, and a test in `contract.spec.ts` pins that. ## Instance members diff --git a/packages/entity/consumer/index.ts b/packages/entity/consumer/index.ts index b716fe9..26c1a64 100644 --- a/packages/entity/consumer/index.ts +++ b/packages/entity/consumer/index.ts @@ -29,8 +29,8 @@ export class Organization extends Entity("Organization")( /** The statics must still yield the subclass, not the structural base. */ export const load = (raw: unknown): Organization => Organization.make(raw).getOrThrow(); -/** Nesting must still work from outside the package. */ -export const Aggregate = z.object({ owner: Organization.instance }); +/** The class must still compose as a schema from outside the package. */ +export const Aggregate = z.object({ owner: Organization }); // @ts-expect-error construction stays sealed for a consumer new Organization({ id: "x" as never, slug: "y" as never }); diff --git a/packages/entity/src/computed.ts b/packages/entity/src/computed.ts index 9dc53f2..cc1f486 100644 --- a/packages/entity/src/computed.ts +++ b/packages/entity/src/computed.ts @@ -3,7 +3,7 @@ import type { z } from "zod"; import type { OnlyNominal } from "./shape.js"; /** One derived field: its schema, and the function that produces it. */ -export type ComputedField = { +export type ComputedField = { readonly schema: T; readonly from: (d: D) => z.infer; }; @@ -24,7 +24,7 @@ export type ComputedField = { * type is checked against *this* field's schema — a wrong brand reports on the * field that produced it rather than on the whole map. */ -export function computed( +export function computed( schema: T & OnlyNominal<{ value: T }>["value"], from: (d: D) => z.infer, ): ComputedField { diff --git a/packages/entity/src/contract.spec.ts b/packages/entity/src/contract.spec.ts index 53fbcb2..bb34ffe 100644 --- a/packages/entity/src/contract.spec.ts +++ b/packages/entity/src/contract.spec.ts @@ -65,8 +65,8 @@ test("all four ZodObject members convert in both directions", () => { } }); -test("the instance surface has no output representation, which is why it is separate", () => { - expect(() => z.toJSONSchema(ApiKey.instance as never, { io: "output" })).toThrow(); +test("the class-as-schema has no output representation, which is why contracts use the four", () => { + expect(() => z.toJSONSchema(ApiKey as never, { io: "output" })).toThrow(); }); test("no schema leaks the runtime tag", () => { diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 7a3e863..8b062a6 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -5,8 +5,8 @@ import type { z } from "zod"; import type { ComputedField } from "./computed.js"; import { InvalidEntity } from "./errors.js"; import { deepFreeze } from "./freeze.js"; -import { attachInstance } from "./instance.js"; import { renderIssue } from "./issues.js"; +import { attachSchema } from "./schema.js"; import { shape, type OnlyNominal } from "./shape.js"; import type { AsyncEntityFactory, @@ -361,7 +361,7 @@ export function Entity(tag: Tag) { } } - attachInstance>(Base, input); + attachSchema>(Base, input); declarations.set(Base, { fields, options: options as Record | undefined }); /** diff --git a/packages/entity/src/nesting.spec.ts b/packages/entity/src/nesting.spec.ts index 57300d1..0439412 100644 --- a/packages/entity/src/nesting.spec.ts +++ b/packages/entity/src/nesting.spec.ts @@ -18,8 +18,8 @@ class Customer extends Entity("Customer")( /** An aggregate: an entity whose fields are other entities. */ class Order extends Entity("Order")({ id: OrderId, - customer: Customer.instance, - watchers: z.array(Customer.instance), + customer: Customer, + watchers: z.array(Customer), note: Line, }) {} @@ -90,7 +90,7 @@ test("updating a sibling field leaves the nested entity intact", () => { test("an invariant can span the outer entity and a nested one", () => { class Checked extends Entity("Checked")( - { id: OrderId, customer: Customer.instance, note: Line }, + { id: OrderId, customer: Customer, note: Line }, { invariants: (d) => d.note.length >= d.customer.name.length diff --git a/packages/entity/src/instance.spec.ts b/packages/entity/src/schema.spec.ts similarity index 61% rename from packages/entity/src/instance.spec.ts rename to packages/entity/src/schema.spec.ts index a2a9dea..e16a14d 100644 --- a/packages/entity/src/instance.spec.ts +++ b/packages/entity/src/schema.spec.ts @@ -14,21 +14,24 @@ class Organization extends Entity("Organization")( const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", slug: "acme" }; -test("instance parses input data into a class instance", () => { - expect(Organization.instance.parse(raw)).toBeInstanceOf(Organization); +test("the class is itself a schema, so zod parses input to an instance", () => { + // no `.parse` on the class by design — `make` is the direct route, and zod + // reaches the schema through the internal slots + expect(z.array(Organization).parse([raw])[0]).toBeInstanceOf(Organization); + expect(Organization.make(raw).getOrThrow()).toBeInstanceOf(Organization); }); -test("instance nests inside a zod object and an array", () => { - expect(z.object({ owner: Organization.instance }).parse({ owner: raw }).owner).toBeInstanceOf( +test("the class nests inside a zod object and an array", () => { + expect(z.object({ owner: Organization }).parse({ owner: raw }).owner).toBeInstanceOf( Organization, ); - const many = z.array(Organization.instance).parse([raw, raw]); + const many = z.array(Organization).parse([raw, raw]); expect(many[0]).toBeInstanceOf(Organization); }); test("a nested invariant failure names the failing member in the issue path", () => { const result = z - .object({ owner: Organization.instance }) + .object({ owner: Organization }) .safeParse({ owner: { ...raw, slug: "reserved" } }); expect(result.success).toBe(false); if (!result.success) { @@ -37,22 +40,23 @@ test("a nested invariant failure names the failing member in the issue path", () } }); -test("instance is the Standard Schema entry point", () => { - const parse = fromSchema(Organization.instance); +test("the class is the Standard Schema entry point", () => { + const parse = fromSchema(Organization); expect(parse(raw).getOrThrow()).toBeInstanceOf(Organization); expect(parse({ id: "nope", slug: "" }).isErr()).toBe(true); }); -test("instance is not enumerable on the class", () => { - expect(Object.keys(Organization)).not.toContain("instance"); +test("the schema slots are not enumerable on the class", () => { + expect(Object.keys(Organization)).not.toContain("_zod"); + expect(Object.keys(Organization)).not.toContain("~standard"); }); -test("instance is built once and reused", () => { - expect(Organization.instance).toBe(Organization.instance); +test("the schema is built once and reused", () => { + expect(Organization).toBe(Organization); }); -test("instance is a Standard Schema, so it is what a framework receives", () => { - expect("~standard" in (Organization.instance as object)).toBe(true); +test("the class carries ~standard, so a framework accepts it directly", () => { + expect("~standard" in (Organization as object)).toBe(true); }); test("a defect during make propagates instead of becoming a validation issue", () => { @@ -60,20 +64,18 @@ test("a defect during make propagates instead of becoming a validation issue", ( { id: OrgId }, { invariants: () => { - // deliberately simulate an unmodeled defect, to pin that `instance` + // deliberately simulate an unmodeled defect, to pin that the schema // lets it propagate rather than folding it into a zod issue // oxlint-disable-next-line unthrown/no-throw throw new Error("boom"); }, }, ) {} - expect(() => Buggy.instance.parse({ id: raw.id })).toThrow("boom"); + expect(() => z.array(Buggy).parse([{ id: raw.id }])).toThrow("boom"); }); test("a nested entity's field failure reports the full path, not just the member", () => { - const result = z - .object({ owner: Organization.instance }) - .safeParse({ owner: { id: raw.id, slug: "" } }); + const result = z.object({ owner: Organization }).safeParse({ owner: { id: raw.id, slug: "" } }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0]?.path).toEqual(["owner", "slug"]); @@ -82,7 +84,7 @@ test("a nested entity's field failure reports the full path, not just the member test("a nested invariant failure lands on the member itself, having no path", () => { const result = z - .object({ owner: Organization.instance }) + .object({ owner: Organization }) .safeParse({ owner: { ...raw, slug: "reserved" } }); expect(result.success).toBe(false); if (!result.success) { diff --git a/packages/entity/src/instance.ts b/packages/entity/src/schema.ts similarity index 50% rename from packages/entity/src/instance.ts rename to packages/entity/src/schema.ts index 3bf3af6..be2f220 100644 --- a/packages/entity/src/instance.ts +++ b/packages/entity/src/schema.ts @@ -8,7 +8,7 @@ import { keysOf } from "./issues.js"; * The composable surface: input data parsed into a class instance. * * Failures cross into zod's issue channel so a nested entity reports which - * member failed — `z.object({ owner: Organization.instance })` yields + * member failed — `z.object({ owner: Organization })` yields * `path: ["owner"]`. This schema carries a transform, so * `z.toJSONSchema(..., { io: "output" })` throws on it by design; contracts * use the four plain `ZodObject` members instead. @@ -42,41 +42,46 @@ function instanceSchema( } /** - * Attaches `instance` to an entity class as a lazily computed, - * self-overwriting accessor property. + * Makes the entity class itself a zod schema. + * + * `_zod` is the slot zod reads a schema through, and `~standard` is the + * Standard Schema entry point; delegating both to a lazily built transform + * schema is enough for `z.object({ owner: Organization })`, `z.array(...)` + * and an entity field map to accept the class directly. Deliberately *only* + * these two: the full `ZodType` surface would put a throwing `.parse()` on + * every entity beside `make`, which this package exists to avoid. * * The class is only ever consumed through a subclass (`class X extends * Entity(tag)(fields) {}`), which does not exist yet when the entity builder * runs. A plain value would close over the literal base constructor, so - * `X.instance.parse(...)` would build a base instance — not an `X` — - * failing `instanceof X`. A getter instead reads `this` from the access - * site (`X.instance`), which JS's prototype-based static inheritance sets - * to the actual receiver, so it binds to whichever subclass it was read - * from — but a bare getter would rebuild the schema on every access, so - * `X.instance !== X.instance` and every parse would reconstruct the whole - * transform chain. The getter therefore overwrites itself with a plain, - * non-enumerable data property on the same receiver the first time it runs, - * so later reads are free and identity is stable. + * parsing would build a base instance — not an `X` — failing `instanceof X`. + * A getter instead reads `this` from the access site, which JS's + * prototype-based static inheritance sets to the actual receiver, so it binds + * to whichever subclass it was read from — but a bare getter would rebuild the + * schema on every access, so every parse would reconstruct the whole transform + * chain. The schema is therefore memoised per receiver in a `WeakMap`. * - * Caveat: the self-overwrite is first-read-wins *per receiver*, not per - * class. `attachInstance` runs once per `Entity(...)` call, so every entity - * built that way defines its own getter and is unaffected. But a bare `class - * Y extends X {}` — a plain JS subclass with no `Entity(...)` call of its - * own — has no getter of its own; it inherits `X`'s. If `X.instance` is read - * first, the getter on `X` is replaced by `X`'s own data property before `Y` - * ever reads it, and `Y.instance` then resolves to that inherited property: - * `Y.instance.parse(...)` silently builds an `X`, not a `Y`, with no error. */ -export function attachInstance(Base: object, input: z.ZodType): void { - Object.defineProperty(Base, "instance", { - configurable: true, - enumerable: false, - get(this: object) { - const built = instanceSchema(input, (d) => - (this as unknown as { make: (state: unknown) => Result }).make(d), - ); - Object.defineProperty(this, "instance", { value: built, enumerable: false }); - return built; - }, - }); +const schemas = new WeakMap(); + +export function attachSchema(Base: object, input: z.ZodType): void { + const schemaFor = (receiver: object): z.ZodType => { + const cached = schemas.get(receiver); + if (cached !== undefined) return cached; + const built = instanceSchema(input, (d) => + (receiver as unknown as { make: (state: unknown) => Result }).make(d), + ); + schemas.set(receiver, built); + return built; + }; + + for (const slot of ["_zod", "~standard"] as const) { + Object.defineProperty(Base, slot, { + configurable: true, + enumerable: false, + get(this: object) { + return (schemaFor(this) as unknown as Record)[slot]; + }, + }); + } } diff --git a/packages/entity/src/shape.test-d.ts b/packages/entity/src/shape.test-d.ts index 453c195..f82b3b0 100644 --- a/packages/entity/src/shape.test-d.ts +++ b/packages/entity/src/shape.test-d.ts @@ -71,15 +71,15 @@ describe("shape() rejects unbranded scalars", () => { void wrong; }); - test("another entity's `.instance` is a valid field", () => { + test("another entity class is a valid field", () => { class Customer extends Entity("Customer")({ id: Id, name: Name }) {} - shape({ id: Id, customer: Customer.instance }); - shape({ id: Id, watchers: z.array(Customer.instance) }); + shape({ id: Id, customer: Customer }); + shape({ id: Id, watchers: z.array(Customer) }); }); test("a nested entity keeps its behaviour and tag through the field", () => { class Customer extends Entity("Customer")({ id: Id, name: Name }) {} - class Order extends Entity("Order")({ id: Id, customer: Customer.instance }) {} + class Order extends Entity("Order")({ id: Id, customer: Customer }) {} const order = Order.make({}).getOrThrow(); const tag: "Customer" = order.customer._tag; const name: z.infer = order.customer.name; diff --git a/packages/entity/src/shape.ts b/packages/entity/src/shape.ts index d152793..8181f2e 100644 --- a/packages/entity/src/shape.ts +++ b/packages/entity/src/shape.ts @@ -28,7 +28,8 @@ type IsNarrowLiteral = T extends string type StripUndefined = T extends undefined ? never : T; /** - * Another entity, reached through its `.instance` schema. + * Another entity. The class is itself a schema, so it appears in a field + * map directly. * * Checked structurally rather than against `BaseInstance` itself: that * interface is generic in the entity's own shape, and there is no argument @@ -84,7 +85,7 @@ type FieldNameIsReservedByEntity = { */ type ReservedFieldName = "_tag" | "equals" | "toJSON" | "update"; -type OnlyNominal> = { +type OnlyNominal> = { [K in keyof T]: K extends ReservedFieldName ? FieldNameIsReservedByEntity : IsNominalField> extends true @@ -93,7 +94,7 @@ type OnlyNominal> = { }; /** The only sanctioned way to declare a domain shape. */ -export function shape>( +export function shape>( fields: T & OnlyNominal, ): z.ZodObject { return z.object(fields as T); diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index f49c067..bdd0c4d 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -5,7 +5,12 @@ import type { ComputedField as ComputedFieldOf } from "./computed.js"; import type { InvalidEntity } from "./errors.js"; import type { OnlyNominal } from "./shape.js"; -export type Fields = Record; +/** + * A field map's values. `z.core.$ZodType`, not `z.ZodTypeAny`: an entity class + * carries only zod's internal slots, not the full method surface, and must be + * usable as a field. Anything zod accepts in an object shape is accepted here. + */ +export type Fields = Record; /** The data an entity accepts on the wire. */ export type InputOf = z.infer>; @@ -262,22 +267,26 @@ export type EntityStatic< readonly createInput: z.ZodObject>; readonly updateInput: z.ZodObject>; /** - * At runtime `X.instance.parse(...)` yields an actual `X` — `attachInstance` - * (see `instance.ts`) reads the receiver, which JS's prototype-based static - * inheritance sets to whichever subclass `.instance` was read from. The type - * cannot say that: unlike a *method*, a property can't take an explicit - * `this` parameter to infer the receiver's type from the call site (the - * trick `decode`/`make`/`create` and `update` use), and a `this` type - * written directly in the property's type does not repolymorphize per - * subclass on a *static* member the way it does for instance members — this - * was measured, not assumed: `Y extends X {}` still narrows `Y.instance` to - * `X`'s shape. So `instance` is typed as the base shape only; a caller who - * needs the subclass's own members back must narrow explicitly (e.g. - * `instanceof`) after parsing. + * The zod slots that make the class itself a schema, so it composes + * directly: `z.object({ owner: Organization })`, `z.array(Organization)`, + * or as a field of another entity. Parsing yields a real instance. + * + * Only these two are declared, never the full `ZodType`: that would put a + * throwing `.parse()` on every entity beside `make`, which is the opposite + * of what this package is for. Wrapping still works through zod's function + * forms — `z.optional(Organization)` rather than `Organization.optional()`. + * + * The runtime binds to whichever class the slot is read from, so a schema + * built from a subclass yields that subclass. The type cannot say so — a + * property, unlike a method, takes no `this` parameter to infer the receiver + * from — so it states the base shape and a caller narrows with `instanceof`. */ - readonly instance: z.ZodType< + readonly _zod: z.ZodType< + BaseInstance & DeepReadonly> & { readonly _tag: Tag } + >["_zod"]; + readonly "~standard": z.ZodType< BaseInstance & DeepReadonly> & { readonly _tag: Tag } - >; + >["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ readonly __input: InputOf; readonly __output: OutputOf; diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 6439d2a..54ea83f 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -76,16 +76,14 @@ test("input and output generate JSON Schema in both directions, one branch per m } }); -test("instance parses to the member class and nests", () => { - expect(Member.instance.parse(userRow)).toBeInstanceOf(User); - const Wrapper = z.object({ member: Member.instance }); +test("the union is a schema too, parsing to the member class and nesting", () => { + expect(z.array(Member).parse([userRow])[0]).toBeInstanceOf(User); + const Wrapper = z.object({ member: Member }); expect(Wrapper.parse({ member: svcRow }).member).toBeInstanceOf(ServiceAccount); }); test("a nested member failure keeps the outer path", () => { - const result = z - .object({ member: Member.instance }) - .safeParse({ member: { ...userRow, email: "nope" } }); + const result = z.object({ member: Member }).safeParse({ member: { ...userRow, email: "nope" } }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0]?.path).toEqual(["member", "email"]); @@ -98,7 +96,7 @@ test("the members are reachable, for exhaustiveness and registries", () => { }); test("a union member can itself be a field of another entity", () => { - class Audit extends Entity("Audit")({ id: UserId, actor: Member.instance }) {} + class Audit extends Entity("Audit")({ id: UserId, actor: Member }) {} const a = Audit.make({ id: userRow.id, actor: svcRow }).getOrThrow(); expect(a.actor).toBeInstanceOf(ServiceAccount); }); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 106f4f4..528cbdb 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -1,7 +1,8 @@ -import { Err, type Result } from "unthrown"; +import { Err, P, type Result } from "unthrown"; import { z } from "zod"; import { InvalidEntity } from "./errors.js"; +import { keysOf } from "./issues.js"; /** * The part of an entity a union needs. Typed loosely — `EntityStatic` is @@ -11,25 +12,24 @@ type UnionMember = { readonly entityName: string; readonly input: z.ZodObject; readonly output: z.ZodObject; - readonly instance: z.ZodType; make(state: unknown): Result; -}; +} & z.core.$ZodType; /** - * The member's instance type, read off `instance` rather than off `make`. - * `make` is generic in a `this` parameter, which cannot be inferred through a - * loosened member type; `instance` states the same type plainly. + * The member's instance type, read off the member itself — an entity class is + * a schema, so `z.infer` gives what parsing it yields. Not read off `make`, + * which is generic in a `this` parameter and cannot be inferred through a + * loosened member type. */ -type InstanceOf = z.infer; +type InstanceOf = z.infer; export type EntityUnion = { readonly discriminant: K; readonly members: M; readonly input: z.ZodType; readonly output: z.ZodType; - readonly instance: z.ZodType>; make(state: unknown): Result, InvalidEntity>; -}; +} & Pick>, "_zod" | "~standard">; /** * `z.discriminatedUnion` constrains its branches to `$ZodTypeDiscriminable`, @@ -104,15 +104,32 @@ export function union< ctx.addIssue({ code: "custom", message: issue.message, path: [discriminant] }); return z.NEVER; } - const parsed = member.instance.safeParse(raw); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - ctx.addIssue({ code: "custom", message: issue.message, path: [...issue.path] }); - } - return z.NEVER; - } - return parsed.data as InstanceOf; + // through `make`, not a parse method: a member is an entity class, which + // carries zod's slots rather than its methods. Mirrors `instance.ts` — a + // Defect is left unrecovered so it panics rather than becoming an issue. + return member + .make(raw) + .recoverErrCases((m) => + m.with(P.tag("InvalidEntity"), (invalid) => { + for (const issue of invalid.issues) { + ctx.addIssue({ code: "custom", message: issue.message, path: keysOf(issue) }); + } + return z.NEVER; + }), + ) + .get() as InstanceOf; }) as unknown as z.ZodType>; - return { discriminant, members, input, output, instance, make }; + // the same two slots an entity carries, so a union composes identically — + // `z.object({ member: Member })`, or as a field of another entity + const slots = instance as unknown as Record; + return { + discriminant, + members, + input, + output, + make, + _zod: slots["_zod"], + "~standard": slots["~standard"], + } as EntityUnion; }