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

`extend` options now accumulate instead of replacing. `generated` and
`immutable` concatenate root-then-variant, and `computed` merges per key — the
rule `invariants` already followed. A variant adds to what its root declared and
can no longer shed it.

Before, a variant that declared `immutable` replaced the root's list wholesale,
so this silently made `issuedAt` and `issuedTo` patchable:

```ts
// root
abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")(
fields,
{ immutable: ["issuedAt", "issuedTo"] },
) {}
// variant — before this change, the root's two were gone, with no diagnostic
class Invoice extends BillingDocumentBase.extend("Invoice")(fields, {
immutable: ["id", "kind"],
}) {}
```

Now the variant's effective list is all four, and re-stating inherited keys is
unnecessary — delete them.

`computed` merges per key rather than concatenating, because it is a map: a
variant may add a derived field beside the root's, and may redefine one, but
cannot drop it. A redefined key gives the variant's schema and derivation on
`output.shape`, `toJSON()` and `Entity.Output`. One measured caveat: the
**instance** property keeps the root's type intersected in, because a root's
instance type is carried into every variant unmapped and subtracting from it is
what `TS2425` forbids. Read a redefined key off `Entity.Output` where its exact
type matters.

**Breaking, in two ways.**

Relaxing is no longer expressible: `immutable: []` in a variant does not widen
`updateInput`. Code relying on it breaks loudly — `updateInput` shrinks, so the
patch call stops typechecking rather than changing behaviour silently. To fix
it, move the key the other way: a field only some variants need locked comes off
the root's `immutable` and goes on each variant that wants it locked. The end
state is the same, and it is the only direction still expressible — a variant
can add to what the root declared, never subtract from it.

`Entity.Static<…>`'s fourth and fifth arguments are now unions of keys rather
than tuples, so the empty case is `never`:

```ts
// before
type Before = Entity.Static<
"Organization",
{ slug: typeof Slug },
Record<never, never>,
[],
[]
>;
// after
type After = Entity.Static<
"Organization",
{ slug: typeof Slug },
Record<never, never>,
never,
never
>;
```

The tuple form could not express the merge — `readonly [...I, ...I2]` is
rejected with `TS2344`, because TypeScript will not prove the parent's key set is
a subset of the child's through zod's inference chain.

The same `TS2344` loosens the constraint on both. `Entity.Static` and
`Entity.BaseInstance` now take any `PropertyKey` where they previously required a
tuple constrained to `keyof`; tightening one back on its own reintroduces the
error, so it is not fixable asymmetrically. Hand-written entity declarations are
unaffected — the builders still constrain the real call sites — but both are
named in consumers' emitted declarations, which is why it is listed here.

For the same reason there is one new exported name, `MergedComputed` (and
`Entity.MergedComputed`): it is what `extend` hands `Entity.Static` as its
computed map, so it lands in the `.d.ts` of any library that declares a variant.
Not something to write against — written inline, the merge emitted an
unsubstituted type parameter and failed consumers on TypeScript 5.9.3 with
`TS2304: Cannot find name 'A2'`.
32 changes: 22 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ that are not derivable from there:
shape and reported only `TS4020`. Widen the entity and both report it. So a
band of realistic domain widths fails for consumers and passes here —
which is the band issues #31 and #32 shipped through.
A fourth step then **type-checks the emitted `node_modules/.emit-check` with
5.9.3**, because emitting cleanly is not the same as emitting something that
compiles: a dangling type-parameter reference in the output is no emit-time
diagnostic, and one shipped that way (`TS2304`, a bare `A2` from `extend`'s
return type). Never give that step `--skipLibCheck` — it disables `.d.ts`
checking outright and the run passes on broken output. Measured.
That example keeps its abstract root in `src/root.ts`, exported, rather than
beside its variants: a root reaches a variant's `.d.ts` as a synthesised local
`declare abstract class` when the two share a module and as a **named import**
Expand Down Expand Up @@ -107,10 +113,12 @@ 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. Options merge per key, child winning, except
`invariants`, which concatenate — so a variant declaring `computed` **drops**
the root's derived fields. Built against a loosened `BuildEntity` passed in from
`entity.ts`, so this module imports no builder and there is no cycle.
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
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`/
typed-array fields with different contents as **equal**, and reported a
Expand Down Expand Up @@ -174,17 +182,21 @@ design — `contract.spec.ts` pins that both ways.
carry a targeted `oxlint-disable` with a reason — several already exist for
`no-catch-all-pattern` where `SchemaIssues` is a single non-union type.
- **Comments recording measurements are regression guards.** Many comments
cite a specific TS diagnostic code (TS2411, TS2425, TS2509, TS2515, TS2526,
TS4020, TS4111) or a measured library behaviour. The four around roots and
unions: a base constructor may not return a union or a `never`-collapsed
cite a specific TS diagnostic code (TS2344, TS2411, TS2425, TS2509, TS2515,
TS2526, TS4020, TS4111) or a measured library behaviour. The four around roots
and unions: a base constructor may not return a union or a `never`-collapsed
intersection (**TS2509** — `SoleType`, and `RootInstance` widening `_tag` to
`string`), a mapped behaviour type turns a method into a property and breaks
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. 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.
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
`pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the exact versions
those measurements were taken against, with the reason inline.
- **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
Expand Down
29 changes: 20 additions & 9 deletions docs/examples/billing-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export abstract class BillingDocumentBase extends Entity.abstract(
{
generated: ["issuedAt"],
immutable: ["issuedAt", "issuedTo"],
computed: {
period: Entity.computed(
AccountingPeriod,
(d) => d.issuedAt.slice(0, 7) as z.infer<typeof AccountingPeriod>,
),
},
invariants: [
Entity.invariant(
(d) => d.total.amount >= 0,
Expand All @@ -117,8 +123,8 @@ export abstract class BillingDocumentBase extends Entity.abstract(
export class Invoice extends BillingDocumentBase.extend("Invoice")(
{ id: InvoiceId, kind: z.literal("INVOICE") /* … */ },
{
generated: ["id", "issuedAt", "kind"],
immutable: ["id", "issuedAt", "issuedTo", "kind"],
generated: ["id", "kind"],
immutable: ["id", "kind"],
/* … invariants, one of them */
},
) {
Expand All @@ -135,13 +141,18 @@ is the other half: behaviour written once and inherited, which is what a
rebuilt-from-the-declaration extension could not carry. An entity itself is
final; `extend` lives only here.

Note what the variants re-state. `generated`, `immutable` and `computed`
**replace** the root's entry for that key rather than adding to it — two key
lists and a map of derived fields — so `Invoice` names `issuedAt` and
`issuedTo` again alongside its own. Only `invariants`
concatenate — the root's "total must not be negative" applies to both variants
whether or not they declare rules of their own, and `Invoice` does declare one
of its own ("a void invoice cannot be in dunning").
Note what the variants do **not** state. Every option accumulates,
root-then-variant, so `Invoice` names only the keys it introduces: `issuedAt` is
generated and `issuedAt`/`issuedTo` immutable because the root said so, and the
variant adding `id` and `kind` does not disturb that. `computed` accumulates too,
merging per key rather than concatenating: `period` — the accounting period,
derived from `issuedAt`, because reports work per period and a stored copy could
disagree with the date — is on every variant without either of them naming it.
`invariants` work the same way: the root's "total must not be negative" applies
to both variants whether or not they declare rules of their own, and `Invoice`
declares one of its own ("a void invoice cannot be in dunning"). The spec pins
the inheritance both ways — patching `issuedAt` on an invoice is refused, and
`invoice.period` is derived, though `Invoice` mentions neither.

## Nesting, and the factory

Expand Down
9 changes: 5 additions & 4 deletions docs/how-to/evolve-an-entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,11 @@ The old rows are otherwise untouched: `_tag` moves from `"Document"` to
`"Invoice"`, but it is non-enumerable and never stored, so nothing on disk knows
the difference. Anything reading `entityName`, or matching on `P.tag`, does.

Options declared on the root are inherited, and `invariants` **concatenate** —
a variant can add rules but never shed the root's. Every other option replaces
the root's list for that key, so a variant declaring `generated` or `immutable`
re-states every key it needs.
Options declared on the root are inherited, and a variant **adds** to them: name
only the keys and rules the variant itself introduces, and the root's still
apply. Nothing a root declared can be shed — `immutable: []` on a variant does
not widen its `updateInput`.
([How each option merges](/reference/declaration#root-extend-tag-fields-options).)

## Computed fields heal themselves

Expand Down
53 changes: 40 additions & 13 deletions docs/reference/declaration.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,19 +257,46 @@ class Personal extends AccountBase.extend("Personal")({
}
```

Options merge per key, child winning — **except `invariants`**, which
concatenates root-then-variant. A variant can add rules; it cannot shed them, so
it is never quietly laxer than its root. Declaring `invariants: []` on a variant
does not clear the root's.

Every other option — `generated`, `immutable` **and `computed`** — **replaces**
the root's for that key. A variant declaring one of them re-states every entry
it needs, including the root's; one that declares none inherits all three whole.

`computed` is the one worth watching, because what it drops is a column rather
than a rule: a variant that declares a derived field of its own loses every
derived field the root declared, and the loss is only visible in
`Variant.output.shape`.
Options **accumulate**, root-then-variant. A variant adds to what it inherits
and cannot shed it, so it is never quietly laxer than its root.

| Option | How a variant's declaration meets the root's |
| ------------ | ------------------------------------------------------------- |
| `generated` | concatenated, root-then-variant |
| `immutable` | concatenated, root-then-variant |
| `invariants` | concatenated, root-then-variant |
| `computed` | merged **per key** — a repeated key takes the variant's entry |

A variant names only what it adds. `Personal` above declares no options and
inherits everything `AccountBase` declared; a variant declaring
`immutable: ["kind"]` is immutable in `kind` **and** in every key the root
listed.

Relaxing is not expressible. `immutable: []` on a variant does not widen
`updateInput`, and `invariants: []` does not clear the root's rules — an empty
list contributes nothing, which is not the same as taking something away.

The key lists are not deduplicated, and do not need to be. Each is turned into a
keyed lookup before it reaches a schema or a patch check, so naming a key the
root already declared is harmless.

`computed` merges per key rather than concatenating, because it is a map. A
variant can add a derived field beside the root's, and can **redefine** one the
root declared — its schema and its derivation replace that entry alone — but
cannot drop one.

Redefining an inherited computed key has one edge, measured. The variant's
derivation is what runs, and every surface read off the declaration agrees with
it: `Variant.output.shape`, `toJSON()` and `Entity.Output<typeof Variant>` all
carry the variant's schema. The **instance property** does not — it keeps the
root's type intersected in, so a key the root branded `Upper` and the variant
rebranded `Label` reads as `Upper & Label` on an instance, and is still
assignable where the root's brand is expected. The root's instance type is
intersected into every variant **unmapped**, and subtracting a key from it is
exactly what `TS2425` forbids: any mapped form turns the root's methods into
function-typed properties and breaks every variant implementing an `abstract`
member. There is no fix pending; read the field off
`Entity.Output<typeof Variant>` where its exact type matters.

`extend` lives only on a root. The entity it returns is final.

Expand Down
30 changes: 22 additions & 8 deletions docs/reference/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ surrounding declaration.

## The declaration-emit names

Seven types are exported at the top level: `AbstractEntity`, `BaseInstance`,
`ConstructionKey`, `EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember`. They
are the one exception to the single-import rule, and none of them is part of the
API you write against. Six also have namespace aliases for anyone annotating by
hand — `Entity.Abstract`, `Entity.BaseInstance`, `Entity.ConstructionKey`,
`Entity.Sealed`, `Entity.Static`, `Entity.Union` — but a consumer's _emitted
declarations_ use the top-level names.
Eight types are exported at the top level: `AbstractEntity`, `BaseInstance`,
`ConstructionKey`, `EntityStatic`, `EntityUnion`, `MergedComputed`, `Sealed`,
`UnionMember`. They are the one exception to the single-import rule, and none of
them is part of the API you write against. Seven also have namespace aliases for
anyone annotating by hand — `Entity.Abstract`, `Entity.BaseInstance`,
`Entity.ConstructionKey`, `Entity.MergedComputed`, `Entity.Sealed`,
`Entity.Static`, `Entity.Union` — but a consumer's _emitted declarations_ use the
top-level names.

```ts
import type {
Expand All @@ -62,6 +63,7 @@ import type {
ConstructionKey,
EntityStatic,
EntityUnion,
MergedComputed,
Sealed,
UnionMember,
} from "@btravstack/entity";
Expand All @@ -86,13 +88,25 @@ top-level name. What each one buys was measured, not assumed:
writing `abstract class X extends Entity.abstract("X")(…) {}` emits the
underlying name into its declarations, not the `Entity.Abstract` path that
aliases it.
- **`MergedComputed`** — a root's computed map merged with a variant's, which
`extend` hands `EntityStatic` as its `A`. Written inline as
`Omit<A, keyof A2> & A2`, TypeScript 5.9.3 copied the type parameter `A2`
through unsubstituted whenever the root declared no `computed` — the default
— so consumers' declarations carried a name that resolved to nothing and
failed with `TS2304: Cannot find name 'A2'`. 7.0.2 substitutes the same
position correctly, so only downstream builds saw it. Naming it is half the
fix and exporting it is the other half: unexported, the emitter expands the
alias structurally again and the identical dangling `A2` comes back.
- **`EntityUnion`, `UnionMember`** — the same story for
`Entity.union(...)` assigned to an exported `const`: without a top-level
name the members expand structurally and reach `$brand`, failing with
`TS4023: Exported variable … cannot be named`. `UnionMember` travels with
`EntityUnion` because it is that type's own constraint.

A fixture in CI compiles a consumer with declaration emit against the built
types, so none of this can regress. See
types, on two TypeScript versions, and then **type-checks what it emitted** —
which is not the same guarantee: `MergedComputed` above was found only because
that last step exists, since a dangling reference in the output is no emit-time
diagnostic. See
[Sealed construction](/explanation/sealed-construction) for what the seal buys
and what the two rejected alternatives cost.
1 change: 1 addition & 0 deletions docs/typedoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"Invariant",
"InvariantSrc",
"IsNominalField",
"MergedComputedSrc",
"OnlyNominal",
"OutputOf",
"PatchOf",
Expand Down
2 changes: 1 addition & 1 deletion examples/billing-domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json"
"typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 node_modules/.emit-check/index.d.ts node_modules/.emit-check/emit-guards.d.ts node_modules/.emit-check/index.spec.d.ts"
},
"dependencies": {
"@btravstack/entity": "workspace:*",
Expand Down
Loading
Loading