Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/entity-is-a-schema.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 8 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, InvalidEntity>` instead of
throwing.

Expand Down Expand Up @@ -66,12 +66,11 @@ Eight source modules under `packages/entity/src`, split by what they own:
`Sealed<D>`, 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.
Expand All @@ -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.
Comment thread
btravers marked this conversation as resolved.

## Binding conventions
Expand Down
77 changes: 45 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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<Organization> — 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`,
Expand All @@ -158,16 +159,16 @@ 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:

```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();
Expand All @@ -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<Organization, InvalidEntity>
z.optional(Organization); // ✓
Organization.parse(raw); // ✗ does not exist
Organization.optional(); // ✗ does not exist
```

## The three entry points

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
28 changes: 14 additions & 14 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/entity/consumer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions packages/entity/src/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends z.ZodTypeAny, D> = {
export type ComputedField<T extends z.core.$ZodType, D> = {
readonly schema: T;
readonly from: (d: D) => z.infer<T>;
};
Expand All @@ -24,7 +24,7 @@ export type ComputedField<T extends z.ZodTypeAny, D> = {
* 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<T extends z.ZodTypeAny, D>(
export function computed<T extends z.core.$ZodType, D>(
schema: T & OnlyNominal<{ value: T }>["value"],
from: (d: D) => z.infer<T>,
): ComputedField<T, D> {
Expand Down
4 changes: 2 additions & 2 deletions packages/entity/src/contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -361,7 +361,7 @@ export function Entity<Tag extends string>(tag: Tag) {
}
}

attachInstance<Base & DeepReadonly<OutputShape>>(Base, input);
attachSchema<Base & DeepReadonly<OutputShape>>(Base, input);
declarations.set(Base, { fields, options: options as Record<string, unknown> | undefined });

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/entity/src/nesting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) {}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading