Skip to content
129 changes: 129 additions & 0 deletions .changeset/entity-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
"@btravstack/entity": minor
---

Add `Entity.field(schema, flags)` and move `generated` / `immutable` off the
options object onto the fields themselves.

```ts
// before
class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
},
) {}

// after
class Organization extends Entity("Organization")({
id: Entity.field(OrgId, { generated: true, immutable: true }),
slug: Entity.field(Slug, { immutable: true }),
name: DisplayName,
createdAt: Entity.field(Instant, { generated: true, immutable: true }),
}) {}
```

A field that carries no flag stays a bare schema. The flags argument is
required — the function exists to flag — and a misspelled flag name is now a
compile error: a constraint is not an excess-property check, so
`{ generated: true, imutable: true }` used to compile clean and leave the field
silently mutable.

Nothing changes at runtime for a declaration that migrates one-for-one:
`createInput`, `updateInput`, the factory's generator map and `update`'s
rejection all derive from the same key sets, now read off the field map instead
of two lists beside it.

**Cost, measured.** A consumer's emitted declarations grow ~90 bytes per
_appearance_ of a flagged field — the billing-domain fixture's 10 flagged
fields appear 21 times across its `.d.ts` set, for +1,894 B / +8.0% in total.
The appearance count is a property of a domain's shape (a root shared by two
variants, an entity held as another entity's field), not a constant. The naive
design measured +57.8%; see the third item below for why this one does not.

## Breaking: the `generated` and `immutable` options are gone

Both keys are rejected on the options object of `Entity(tag)(…)`,
`Entity.abstract(name)(…)` and `Root.extend(tag)(…)`. `computed` and
`invariants` are what remains, and an entity declaring neither passes no options
object at all. The migration is mechanical:

| Before | After |
| ------------------------------------------ | ------------------------------------------------------------ |
| `{ generated: ["id"] }` | `id: Entity.field(Id, { generated: true })` |
| `{ immutable: ["id"] }` | `id: Entity.field(Id, { immutable: true })` |
| `{ generated: ["id"], immutable: ["id"] }` | `id: Entity.field(Id, { generated: true, immutable: true })` |
| a key in neither list | the bare schema, unchanged |

A key that appeared in a list but not in the field map was already a compile
error and has no migration.

## Breaking: `Entity.Static`, `Entity.Abstract` and `Entity.BaseInstance` lost type parameters

| Type | Now | Was |
| --------------------- | ----------------- | -------------------- |
| `Entity.Static` | `<Tag, S, A, B?>` | `<Tag, S, A, G, I>` |
| `Entity.Abstract` | `<Name, S, A>` | `<Name, S, A, G, I>` |
| `Entity.BaseInstance` | `<S, A>` | `<S, A, I>` |

Their top-level spellings moved with them: `EntityStatic<Tag, S, A, B?>` (six
parameters to four — it was the one place `B` was already exposed),
`AbstractEntity<Name, S, A>` and `BaseInstance<S, A>`. The dropped parameters were the generated-
and immutable-key unions; they are computed inside each body from the flags `S`
carries. Hand-written annotations drop the extra arguments —
`Entity.Static<"Org", S, A, never, never>` becomes
`Entity.Static<"Org", S, A>`. Declarations infer them and need no change.

This is the reason the size cost above is +8.0% rather than +57.8%. A key union
in **type-argument** position cannot be de-aliased: the printer re-carries the
whole field map at every appearance, and an alias annotation, a defaulted
parameter plus `infer`, and a mapped-object indirection were each measured to
reconstitute the alias on both TypeScript 7.0.2 and 5.9.3. Computed inside a
body, `S` prints by name and the map appears once — with zero `GeneratedKeys<`
or `ImmutableKeys<` anywhere in the emitted output.

## Breaking: a variant may not redeclare a field its root declares

```ts
abstract class AccountBase extends Entity.abstract("Account")({
id: AccountId,
label: Label,
}) {}

AccountBase.extend("Clash")({ label: Label }); // ✗ FieldAlreadyDeclaredByTheRoot
```

This breaks a variant that restates an inherited field **even with no flags on
either side**, which previously compiled and simply re-declared the same schema.
The migration is to declare the field once, on the root, and delete it from the
variant. A variant that redeclared a key with a _different_ schema was already
reporting that key inconsistently (the instance property kept both brands
intersected, `TS2425`); it now has to pick one and put it on the root.

The compile error is backed by a **declaration-time defect**, thrown while the
declaration is on the stack, so a declaration reaching `extend` from JavaScript
or through a cast fails the same way:

```
Clash: field(s) "label" already declared by the root — a variant adds fields,
it does not redeclare them.
```

`computed` is unaffected: it still merges per key, and a variant may still
replace one of the root's derivations.

## Breaking: a variant can no longer flag a root-declared field

Under the old options accumulation, a variant could add `immutable: ["rootKey"]`
and tighten a field the root declared. There is no spelling for that now, and
the previous item is why: the only place a flag can be written is a field's
declaration, and the field is declared on the root.

Move the flag to the root, where every variant inherits it — flags ride the
field-map spread, so a variant gets them with the fields. If two variants
genuinely need different flags on the same key, they are not sharing that field:
declare it separately on each variant and leave it off the root.

Relaxing was never expressible and still is not: `immutable: []` did not widen
`updateInput` before, and there is no flag that reopens an inherited field now.
66 changes: 49 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ cannot run against it. Measured — the reason is inline in

## Architecture

Twelve source modules under `packages/entity/src` besides `index.ts`, split by
Thirteen source modules under `packages/entity/src` besides `index.ts`, split by
what they own:

- **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the
four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from
one field map plus `generated` / `immutable` / `computed`,
one field map — the `generated` / `immutable` flags its entries carry —
plus `computed`,
then returns a `Base` class carrying them as statics. `create` delegates to
`make`; `update` delegates to `make`; every path funnels through
`construct`, which runs `invariants` and seals the constructor call. Data
Expand All @@ -99,7 +100,7 @@ what they own:
`JSON.stringify`, or spread. `toJSON()` is the **only** public projection —
it, `equals` and `update` all route through a module-private `project`, so
there is no second public spelling of the same data. It also carries the
whole public surface: `Entity.computed` / `Entity.invariant` /
whole public surface: `Entity.field` / `Entity.computed` / `Entity.invariant` /
`Entity.abstract` / `Entity.union` / `Entity.InvalidEntity` as expando
properties, and every public type in a
merged `declare namespace Entity`. Namespace members alias imported types
Expand All @@ -120,11 +121,14 @@ what they own:
class-body **field** is typed but never initialised (the variant's generated
base extends nothing, so a root's constructor never runs), and the
construction seal is unaffected. `docs/reference/declaration.md` states all
three; `base.spec.ts` pins them. Every option **accumulates** root-then-child:
`generated`, `immutable` and `invariants` concatenate, and `computed` merges
**per key**, so a variant can add or redefine a derived field but never drop
the root's. Relaxing is not expressible — `immutable: []` on a variant is a
no-op. Built against a loosened `BuildEntity` passed in from `entity.ts`, so
three; `base.spec.ts` pins them. A variant **accumulates** onto the root:
`invariants` concatenate, `computed` merges **per key**, and the flags need no
merging at all — they ride the field-map spread, wrapped, so a variant
inherits them with the fields. Relaxing is not expressible. **Redeclaring an
inherited field is forbidden**, flagged or not: a compile error naming
`FieldAlreadyDeclaredByTheRoot`, plus a declaration-time defect naming the
keys and the tag — a bare-schema redeclaration used to drop the root's flags
silently. Built against a loosened `BuildEntity` passed in from `entity.ts`, so
this module imports no builder and there is no cycle.
- **`equal.ts`** — `deepEqual`, the primitive behind `equals`. Not
`JSON.stringify`: that **threw** on a `bigint` field, compared `Set`/`Map`/
Expand Down Expand Up @@ -159,6 +163,19 @@ what they own:
the members share and falls back to the empty type when they share none;
`Plain` strips that root's abstractness, which a union could never implement.
`Entity.Instance<typeof Account>` is where the exact member union lives.
- **`field.ts`** — `field(schema, flags)`, public as `Entity.field`, and the
`FieldSpec` it returns: a plain `{ schema, flags }` record, never a proxy or a
subclass, because anything standing in front of an entity-class field breaks
`make`, which constructs through `this` (`TypeError: Ctor is not a constructor`
— measured). Two spellings in the signature are load bearing and
both are commented there: `flags` is intersected with a mapped rejection so a
misspelled key is a compile error (a constraint is not an excess-property
check — `{ generated: true, imutable: true }` compiled clean and left the
field mutable), and `schema` is **bare `T`**, never intersected with
`OnlyNominal`, because an intersection at an inference site broke zod's
`$ZodBranded` alias preservation across every branded field (measured, −874 B
over the billing fixture's emitted `.d.ts`). The nominal check lives at the
field map, which already unwraps `FieldSpec` through `SchemaOf`.
- **`shape.ts`** — `OnlyNominal`, the type-level check rejecting unbranded
fields, and `shape()`, which builds the validated field map. Both are
internal; neither is exported from `index.ts`.
Expand Down Expand Up @@ -197,26 +214,41 @@ design — `contract.spec.ts` pins that both ways.
a variant's `override` (**TS2425** — `BehaviourOf`, which must stay unmapped),
and abstractness **does** propagate through the intersection (**TS2515**),
which is why a root's `abstract` member binds every variant and why `Plain`
strips it back off for the union. The accumulating `extend` options add a
fifth: `EntityStatic`'s `G`/`I` are key **unions**, not tuples, because
`readonly [...I, ...I2]` is rejected with **TS2344** — TypeScript will not
prove the parent's key set is a subset of the child's through zod's inference
chain. Verify before "simplifying" them away — the catalog in
strips it back off for the union. Verify before "simplifying" them away — the
catalog in
`pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the exact versions
those measurements were taken against, with the reason inline.
- **The dead-end ledger: a key union in type-argument position cannot be
de-aliased.** `GeneratedKeys<S>` / `ImmutableKeys<S>` are computed **inside**
`EntityStatic` / `AbstractEntity` / `BaseInstance`, never passed as type
arguments, and the comment on `GeneratedKeys` in `types.ts` is the record.
In argument position the printer re-carries the whole field map at every
appearance — the spike measured **+57.8%** on the billing fixture's emitted
declarations, +104% on `index.d.ts` alone — and three attempts to make the
emitter write the alias instead all failed on **both** 7.0.2 and 5.9.3: an
alias annotation, a defaulted parameter plus `infer`, and a
mapped-object-plus-`keyof` indirection each reconstituted the alias through
union-origin tracking. The fix is **arity reduction**, not a better spelling:
`Entity.Static<Tag, S, A, B?>`, `Entity.Abstract<Name, S, A>`,
`Entity.BaseInstance<S, A>`. Inside a body `S` prints by name and the map
appears once — measured at **+8.0%** total, ~90 B per flagged-field
appearance, with **zero** `GeneratedKeys<` / `ImmutableKeys<` in the emitted
`.d.ts` set. That grep is the acceptance test; do not move these into a
parameter list.
- **Type-level behaviour lives in `*.test-d.ts`**, checked by
`tsc --noEmit -p tsconfig.test-d.json`. They are excluded from the main tsc
pass, from oxlint, and from knip. Changing a compile-time guarantee (the
seal, `generated`/`immutable` rules, `computed`'s contextual typing) means
seal, the `generated`/`immutable` flags, the redeclaration forbid,
`computed`'s contextual typing) means
updating the matching `@ts-expect-error` assertion.
- **One concept, one name.** The surface is meant to stay small enough that the
library can be "done". Resist convenience aliases.
- **`index.ts` exports `Entity`, and nothing else you write against.** A bare
`computed` or `union` is too generic to take from a consumer's import scope,
so everything hangs off the builder. The sole exception is the nine
so everything hangs off the builder. The sole exception is the ten
declaration-emit names — `AbstractEntity`, `BaseInstance`, `ConstructionKey`,
`EntityStatic`, `EntityUnion`, `MergedComputed`, `MergedFields`, `Sealed`,
`UnionMember` — exported at the top
`EntityStatic`, `EntityUnion`, `FieldSpec`, `MergedComputed`, `MergedFields`,
`Sealed`, `UnionMember` — exported at the top
level as well: a downstream
library compiling with `declaration: true` emits the _underlying_ name, not
the namespace path aliasing it, so hiding them fails the consumer pass with
Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ 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"],
id: Entity.field(OrgId, { generated: true, immutable: true }),
slug: Entity.field(Slug, { immutable: true }),
name: DisplayName,
createdAt: Entity.field(Instant, { generated: true, immutable: true }),
},
{
computed: {
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
},
Expand Down Expand Up @@ -94,7 +97,7 @@ await db.insert(org.toJSON());
const loaded = Organization.make(row).getOrThrow();

// 5. Update. Returns a NEW entity; invariants re-run; immutable fields are a
// compile error and are dropped at runtime if smuggled past it.
// compile error, and rejected at runtime if smuggled past it.
const renamed = loaded.update({ name: nextName }).getOrThrow();

// 6. Respond. The four schema members are plain `ZodObject`s, so a contract
Expand All @@ -120,8 +123,8 @@ Organization.make({ ...row, name: "" }).match({
| ------------- | ----------- | ------------------------------------------------------------------------ |
| `input` | `ZodObject` | everything `make()` accepts |
| `output` | `ZodObject` | stored state and response body |
| `createInput` | `ZodObject` | create request — `input` minus `generated` |
| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial |
| `createInput` | `ZodObject` | create request — `input` minus the `generated` fields |
| `updateInput` | `ZodObject` | update request — `output` minus the `immutable` fields, partial |
| _the class_ | zod schema | parses to an instance; valid as a field, and anywhere zod takes a schema |

| Entry point | Takes | For |
Expand All @@ -131,10 +134,13 @@ Organization.make({ ...row, name: "" }).match({
| `entity.update(patch)` | a partial of the mutable fields | an update use case |
| `entity.toJSON()` | — | the stored data, for a write or a response |

| Field flag | `Entity.field(schema, …)` | Meaning |
| ----------- | ------------------------- | ------------------------------------------------ |
| `generated` | `{ generated: true }` | the domain supplies this field, never the caller |
| `immutable` | `{ immutable: true }` | it never changes after creation |

| Option | Meaning |
| ------------ | ----------------------------------------------------------------------- |
| `generated` | fields the domain supplies, never the caller |
| `immutable` | fields that never change after creation |
| `computed` | fields derived from the declared ones, re-derived on every construction |
| `invariants` | rules built with `Entity.invariant`; any failing rule rejects |

Expand Down
6 changes: 3 additions & 3 deletions docs/examples/billing-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export const UpdateOrganizationBody = Organization.updateInput;
export const OrganizationResponse = Organization.output;
```

There is nothing to maintain here. `createInput` is the field map minus whatever
the entity declares `generated`; `updateInput` is it minus `immutable` and minus
the computed fields, every remaining key optional. Add a generated field to the
There is nothing to maintain here. `createInput` is the field map minus every
field flagged `generated`; `updateInput` is it minus the ones flagged
`immutable` and minus the computed fields, every remaining key optional. Add a generated field to the
entity and the create body follows on its own — that is the omit list nobody had
to write, and the spec asserts it by checking the generated JSON Schema has
exactly `name` and `slug`.
Expand Down
Loading
Loading