diff --git a/.changeset/computed-self-reference-annotation.md b/.changeset/computed-self-reference-annotation.md new file mode 100644 index 0000000..7b5046c --- /dev/null +++ b/.changeset/computed-self-reference-annotation.md @@ -0,0 +1,14 @@ +--- +"@btravstack/entity": patch +--- + +Document the self-referencing deriver idiom (#60): a `computed` deriver or an +`Entity.invariant` predicate may call the entity's own statics, given an +explicit return annotation — `(d): boolean => Doc.isActive(d.tags)`. The +unannotated form is `TS2506`, which is TypeScript resolving the deriver's +inferred return type inside the class's own base expression, not a rule of +the library. The annotation is still checked against both the body and the +schema. `computed.test-d.ts` pins the idiom, the wrong-annotation errors, and +the `this`-parameter dead end (`TS2502`). + +Documentation only; no runtime or API change. diff --git a/docs/explanation/computed-fields.md b/docs/explanation/computed-fields.md index 4ed8dde..f6abc3a 100644 --- a/docs/explanation/computed-fields.md +++ b/docs/explanation/computed-fields.md @@ -1,6 +1,6 @@ --- title: Why computed re-derives -description: Why a derived field is recomputed on every construction instead of stored, why make validates against input, and when to use a getter instead. +description: Why a derived field is recomputed on every construction instead of stored, why make validates against input, when to use a getter instead, and how a deriver calls the entity's own statics. --- # Why `computed` re-derives @@ -32,3 +32,48 @@ not in the data. The rule: [Persist and rehydrate](/how-to/persist-and-rehydrate#computed-columns-heal-themselves) shows the self-healing read against a real table. + +## Self-referencing derivers + +A deriver may call the entity's own statics. The return annotation on the +deriver is what makes it compile: + +```ts +class Doc extends Entity("Doc")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + active: Entity.computed(z.boolean(), (d): boolean => + Doc.isActive(d.tags), + ), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} +``` + +Leave the annotation off and TypeScript reports `TS2506: 'Doc' is referenced +directly or indirectly in its own base expression`, plus `TS7024` on the +deriver — and the failure cascades, so the error surface looks far larger than +the one line causing it. The self-reference is not the problem; the deriver's +**inferred** return type is. The options object sits inside the `extends` +clause, and inferring the arrow's return type forces the class to resolve +mid-declaration. The annotation preempts that inference, and the body is +checked later, once the class exists. + +The annotation is not an escape hatch — it is checked on both faces, and both +report as `TS2322` on the deriver's return expression: a body disagreeing with +the annotation, and an annotation disagreeing with the schema (or widened to +`unknown`). And `d` stays contextually typed: an undeclared field is still a +compile error. + +The same rule and the same fix apply to `Entity.invariant`. + +Two edges. A variant calling its **root's** statics needs no annotation — the +root is an already-settled class. And `this` cannot work at all: a +`this: typeof Doc` parameter is a type annotation inside the class's own +declaration (`TS2502`), and the library cannot supply the type on your behalf, +because the statics live in a class body TypeScript has not formed yet. diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 8f2dd77..8a3678c 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -137,6 +137,11 @@ computed: { against **that field's** schema. A computed field cannot read another computed field — every derivation is a function of declared data only. +A deriver may call the entity's **own** statics, given an explicit return +annotation — `(d): boolean => Doc.isActive(d.tags)`. Why the unannotated form +is `TS2506`: +[Self-referencing derivers](/explanation/computed-fields#self-referencing-derivers). + Output that fails its own schema is a `Defect`, named for the field (`Person.computed.initials: …`). @@ -178,6 +183,10 @@ is already a Defect rather than something to re-check here. A predicate that throws is a Defect, not an `InvalidEntity`, on the same reasoning as `computed`. +A predicate calling the entity's **own** statics needs the same explicit +return annotation as a +[computed deriver](/explanation/computed-fields#self-referencing-derivers). + ## `Entity.abstract(name)(fields, options?)` A **root**: the fields and the behaviour several entities share, in a class that diff --git a/packages/entity/src/computed.test-d.ts b/packages/entity/src/computed.test-d.ts new file mode 100644 index 0000000..b25fc0a --- /dev/null +++ b/packages/entity/src/computed.test-d.ts @@ -0,0 +1,234 @@ +import { test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const Id = z.uuid().brand("Id"); +const Tag = z.enum(["ARCHIVED", "PRIORITY"]); +type Tags = readonly ("ARCHIVED" | "PRIORITY")[]; + +// The idiom (issue #60): a deriver may call the entity's own statics, given an +// explicit return annotation. The self-reference is not what breaks — the +// deriver's *inferred* return type is: TypeScript resolves a context-sensitive +// arrow's return eagerly inside the heritage-clause call, and the body's +// `Doc.isActive` then needs the class type mid-resolution. The annotation +// preempts body inference; the body itself is checked later, once the class +// exists. Measured on 7.0.2 and 5.9.3; no library-side spelling rescues the +// unannotated form (NoInfer, `unknown` return position, currying — all fail). + +class Doc extends Entity("Doc")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + active: Entity.computed(z.boolean(), (d): boolean => Doc.isActive(d.tags)), + }, + invariants: [Entity.invariant((d): boolean => Doc.isActive(d.tags), "must be active")], + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +abstract class Root extends Entity.abstract("Root")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + active: Entity.computed(z.boolean(), (d): boolean => Root.isActive(d.tags)), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +class Variant extends Root.extend("Variant")( + { note: z.string().brand("Note") }, + { + computed: { + // own static: annotated, like any self-reference + loud: Entity.computed(z.boolean(), (d): boolean => Variant.isLoud(d.note)), + // the ROOT's static: no annotation needed — the root is a settled class + calm: Entity.computed(z.boolean(), (d) => !Root.isActive(d.tags)), + }, + }, +) { + static isLoud(note: string): boolean { + return note === note.toUpperCase(); + } +} + +test("the computed field derived through the entity's own static reaches the instance", () => { + const d = Doc.make({ id: "x", tags: [] }).getOrThrow(); + const active: boolean = d.active; + void active; + const v = Variant.make({ id: "x", tags: [], note: "n" }).getOrThrow(); + const both: [boolean, boolean] = [v.loud, v.calm]; + void both; +}); + +// The annotation is not an escape hatch — it is checked on both faces. + +class WrongBody extends Entity("WrongBody")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error the static's string is not assignable to the `boolean` annotation + active: Entity.computed(z.boolean(), (d): boolean => WrongBody.label(d.tags)), + }, + }, +) { + static label(tags: Tags): string { + return tags.join(","); + } +} + +class WrongSchema extends Entity("WrongSchema")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error a `string` deriver is not assignable to a boolean schema + active: Entity.computed(z.boolean(), (d): string => WrongSchema.label(d.tags)), + }, + }, +) { + static label(tags: Tags): string { + return tags.join(","); + } +} + +class TooWide extends Entity("TooWide")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error `unknown` is not assignable to a boolean schema + active: Entity.computed(z.boolean(), (d): unknown => TooWide.isActive(d.tags)), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +// The annotation does not loosen `d` — it stays contextually typed. + +class StillTyped extends Entity("StillTyped")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error `nope` is not a declared field + active: Entity.computed(z.boolean(), (d): boolean => StillTyped.isActive(d.nope)), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +// Dead-end ledger. Three measured spellings that cannot work; each suppression +// below is load-bearing — if one goes unused, the compiler's behaviour changed. + +// Unannotated deriver: TS2506 on the class, TS7024 on the arrow. +// @ts-expect-error TS2506 — the class is referenced in its own base expression +class Unannotated extends Entity("Unannotated")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error TS7024 — the deriver's return type cannot be inferred + active: Entity.computed(z.boolean(), (d) => Unannotated.isActive(d.tags)), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +// Unannotated deriver on an `Entity.abstract` root: same failure mode, same +// fix — the root builder is `base.ts`, a different builder than `entity.ts`. +// @ts-expect-error TS2506 — the class is referenced in its own base expression +abstract class UnannotatedRoot extends Entity.abstract("UnannotatedRoot")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error TS7024 — the deriver's return type cannot be inferred + active: Entity.computed(z.boolean(), (d) => UnannotatedRoot.isActive(d.tags)), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +// Unannotated deriver on a `.extend` variant referencing its OWN static: same +// failure mode, same fix. A variant referencing the ROOT's statics needs no +// annotation (`Variant.calm` above) — but its own statics are no different +// from any other self-reference. +// @ts-expect-error TS2506 — the class is referenced in its own base expression +class UnannotatedVariant extends Root.extend("UnannotatedVariant")( + { note: z.string().brand("Note") }, + { + computed: { + // @ts-expect-error TS7024 — the deriver's return type cannot be inferred + loud: Entity.computed(z.boolean(), (d) => UnannotatedVariant.isLoud(d.note)), + }, + }, +) { + static isLoud(note: string): boolean { + return note === note.toUpperCase(); + } +} + +// Unannotated invariant: same failure mode, same fix. +// @ts-expect-error TS2506 — the class is referenced in its own base expression +class BadRule extends Entity("BadRule")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + invariants: [ + // @ts-expect-error TS7024 — the predicate's return type cannot be inferred + Entity.invariant((d) => BadRule.isActive(d.tags), "must be active"), + ], + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +// A `this` parameter is signature position — never deferred — so it is +// circular even with the return annotated, and the library cannot supply the +// type either: the statics live in a class body TypeScript has not formed yet. +// @ts-expect-error TS2506 — the class is referenced in its own base expression +class ViaThis extends Entity("ViaThis")( + { id: Entity.field(Id, { immutable: true }), tags: z.array(Tag) }, + { + computed: { + // @ts-expect-error TS2502 — `this` is referenced in its own type annotation + active: Entity.computed(z.boolean(), function (this: typeof ViaThis, d): boolean { + return this.isActive(d.tags); + }), + }, + }, +) { + static isActive(tags: Tags): boolean { + return !tags.includes("ARCHIVED"); + } +} + +test("the dead-end classes still type as entities where suppressed", () => { + void [ + Unannotated, + UnannotatedRoot, + UnannotatedVariant, + BadRule, + ViaThis, + WrongBody, + WrongSchema, + TooWide, + StillTyped, + ]; +}); diff --git a/packages/entity/src/computed.ts b/packages/entity/src/computed.ts index cb50a11..224fea3 100644 --- a/packages/entity/src/computed.ts +++ b/packages/entity/src/computed.ts @@ -28,6 +28,13 @@ export type ComputedField = { * expected type at the call site, so `d` needs no annotation, and the return * type is checked against *this* field's schema — a wrong type reports on the * field that produced it rather than on the whole map. + * + * A deriver may call the entity's **own** statics, given an explicit return + * annotation — `(d): boolean => Doc.isActive(d.tags)`. The unannotated form is + * TS2506: the options object sits in the `extends` clause, and inferring the + * arrow's return resolves the class mid-declaration; the annotation preempts + * that, and is still checked against both the body and the schema. Pinned in + * `computed.test-d.ts`. */ export function computed( schema: T & OnlyNominal<{ value: T }>["value"], diff --git a/packages/entity/src/invariant.ts b/packages/entity/src/invariant.ts index 8e0973d..fe1e9bd 100644 --- a/packages/entity/src/invariant.ts +++ b/packages/entity/src/invariant.ts @@ -34,6 +34,11 @@ export type Invariant = { * carries the deferred `ComputedOf` conditional, and `A` is not yet resolved * when this array is checked, so `d` would degrade to a bag of `unknown`. * + * A predicate calling the entity's **own** statics needs an explicit return + * annotation — `(d): boolean => Doc.isActive(d.tags)` — or the class resolves + * inside its own base expression (TS2506). Same idiom as `computed`; pinned in + * `computed.test-d.ts`. + * * `message` takes the data when the text depends on it. Every failing rule in * the list reports, not just the first, and none carries a `path`: an invariant * spans the entity, which is what distinguishes it from a field complaint.