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
14 changes: 14 additions & 0 deletions .changeset/computed-self-reference-annotation.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 46 additions & 1 deletion docs/explanation/computed-fields.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
9 changes: 9 additions & 0 deletions docs/reference/declaration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: …`).

Expand Down Expand Up @@ -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
Expand Down
234 changes: 234 additions & 0 deletions packages/entity/src/computed.test-d.ts
Original file line number Diff line number Diff line change
@@ -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,
];
});
7 changes: 7 additions & 0 deletions packages/entity/src/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export type ComputedField<T extends z.core.$ZodType, D> = {
* 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<T extends z.core.$ZodType, D>(
schema: T & OnlyNominal<{ value: T }>["value"],
Expand Down
5 changes: 5 additions & 0 deletions packages/entity/src/invariant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export type Invariant<D> = {
* carries the deferred `ComputedOf<A>` 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.
Expand Down
Loading