diff --git a/.changeset/entity-extend.md b/.changeset/entity-extend.md new file mode 100644 index 0000000..4a495d4 --- /dev/null +++ b/.changeset/entity-extend.md @@ -0,0 +1,26 @@ +--- +"@btravstack/entity": minor +--- + +New `extend` static on every entity: build a new entity from an existing +one's declaration. It is `SomeEntity.extend(tag)(fields)`, a static on the +class — not a property of the `Entity` builder. + +```ts +class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { + get isAdult(): boolean { + return this.age >= 18; + } +} +``` + +The result is its own entity — own tag, own schemas, own `equals` identity — +rather than a variant of the parent, which is what distinguishes it from the +bare subclassing that remains refused. + +The parent's options are inherited and merged per key, child winning, so an +extension is never quietly laxer than what it extends. Extensions can +themselves be extended. + +`extend` rebuilds from the declaration, so class-body members (a getter, a +method) are not carried over; re-declare them or use a plain function. diff --git a/README.md b/README.md index cc3e9a2..fcf1b3c 100644 --- a/README.md +++ b/README.md @@ -611,10 +611,40 @@ org.cachedSummary = "computed"; // ✓ still writable — it isn't declared data org.toJSON(); // does NOT include cachedSummary — toJSON() projects only the declared schema's keys ``` +## `extend` + +An entity can be extended into a **new** entity carrying its fields plus more: + +```ts +class Person extends Entity("Person")({ id: PersonId, name: Name }) {} + +class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { + get isAdult(): boolean { + return this.age >= 18; + } +} +``` + +The result is its own entity, not a variant of `Person`: its own tag, its own +schemas, and its own identity under `equals` — `child.equals(parent)` is +`false` even when the shared fields match. That is the difference between this +and the bare subclassing below, which has none of those and is refused. + +The parent's options are **inherited and merged per key, child winning**, so an +extension is never quietly laxer than what it extends — `immutable`, +`invariants` and `computed` all carry over unless the child names them. An +extension can itself be extended. + +One limit worth knowing: `extend` rebuilds from the **declaration** — the field +map and the options. A getter written in the parent's class body is part of +neither, so it does not come along. Re-declare it on the extension, or put +shared behaviour in a plain function. + ## Entities are not subclassable -One `extends` is the declaration form. Subclassing the result is not -supported, and fails at construction with a `Defect`: +One `extends` is the declaration form, and `extend` above builds a new entity +from an existing one. Subclassing the _result_ is neither, and fails at +construction with a `Defect`: ```ts class Sub extends Organization {} diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 2886482..7a3e863 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -40,6 +40,16 @@ const resolveAll = async ( const maskOf = (keys: readonly PropertyKey[]) => Object.fromEntries(keys.map((k) => [k, true as const])); +/** + * What each entity was declared with, so `extend` can rebuild from it. Keyed + * by the base class rather than stored on it, so nothing leaks onto the + * public surface or into a consumer's declarations. + */ +const declarations = new WeakMap< + object, + { readonly fields: Fields; readonly options: Record | undefined } +>(); + /** * `class X extends Entity("X")({ …fields }) {}` * @@ -352,6 +362,30 @@ export function Entity(tag: Tag) { } attachInstance>(Base, input); + declarations.set(Base, { fields, options: options as Record | undefined }); + + /** + * A *new* entity carrying this one's fields plus more, under its own tag. + * + * Not subclassing, which stays forbidden: the result is its own + * `Entity(...)` call, so it has a distinct tag, a distinct identity under + * `equals`, and its own schemas. `class X extends Parent {}` would have + * had none of those — same fields, same tag, no way to tell the two apart. + * + * Options merge per key, child winning. Inheriting matters more than it + * might look: silently dropping the parent's `immutable` or `invariants` + * would leave the extension quietly laxer than what it extends. + */ + Object.defineProperty(Base, "extend", { + enumerable: false, + value: (nextTag: string) => (nextFields: Fields, nextOptions?: Record) => { + const parent = declarations.get(Base); + return (Entity as (t: string) => (f: Fields, o?: unknown) => unknown)(nextTag)( + { ...parent?.fields, ...nextFields }, + { ...parent?.options, ...nextOptions }, + ); + }, + }); return Base as unknown as EntityStatic; }; diff --git a/packages/entity/src/extend.spec.ts b/packages/entity/src/extend.spec.ts new file mode 100644 index 0000000..3b0a38f --- /dev/null +++ b/packages/entity/src/extend.spec.ts @@ -0,0 +1,112 @@ +import { P } from "unthrown"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { Entity, computed } from "./index.js"; + +const PersonId = z.uuid().brand("PersonId"); +const Name = z.string().min(1).brand("Name"); +const Age = z.number().int().min(0).brand("Age"); +const Upper = z.string().min(1).brand("Upper"); + +class Person extends Entity("Person")( + { id: PersonId, name: Name }, + { + immutable: ["id"], + computed: { shout: computed(Upper, (d) => d.name.toUpperCase() as z.infer) }, + invariants: (d) => (d.name.length <= 20 ? [] : ["name must be at most 20 chars"]), + }, +) {} + +class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { + get isAdult(): boolean { + return this.age >= 18; + } +} + +const id = "0199b1f4-1b1e-7000-8000-000000000000"; + +test("an extension carries the parent's fields plus its own", () => { + const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); + expect(p.name).toBe("ada"); + expect(p.age).toBe(36); +}); + +test("a class-body getter sees the extended fields", () => { + const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); + expect(p.isAdult).toBe(true); + expect(PersonWithAge.make({ id, name: "kid", age: 9 }).getOrThrow().isAdult).toBe(false); +}); + +test("the extension is its own entity, with its own tag", () => { + const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); + expect(p._tag).toBe("PersonWithAge"); + expect(PersonWithAge.entityName).toBe("PersonWithAge"); + expect(p).not.toBeInstanceOf(Person); +}); + +test("the parent's computed fields carry over and still re-derive", () => { + const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); + expect(p.shout).toBe("ADA"); + expect(p.update({ name: "grace" as z.infer }).getOrThrow().shout).toBe("GRACE"); +}); + +test("the parent's invariants carry over", () => { + const long = "x".repeat(21); + expect(PersonWithAge.make({ id, name: long, age: 36 }).isErr()).toBe(true); +}); + +test("the parent's immutable list carries over", () => { + expect(Object.keys(PersonWithAge.updateInput.shape).toSorted()).toEqual(["age", "name"]); +}); + +test("a child option overrides the parent's for that key", () => { + class Loose extends Person.extend("Loose")({ age: Age }, { invariants: () => [] }) {} + expect(Loose.make({ id, name: "x".repeat(21), age: 1 }).isOk()).toBe(true); +}); + +test("the extension's own schemas include both halves", () => { + expect(Object.keys(PersonWithAge.input.shape).toSorted()).toEqual(["age", "id", "name"]); + expect(Object.keys(PersonWithAge.output.shape).toSorted()).toEqual([ + "age", + "id", + "name", + "shout", + ]); +}); + +test("parent and extension are never equal, even with matching data", () => { + const parent = Person.make({ id, name: "ada" }).getOrThrow(); + const child = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); + expect(child.equals(parent)).toBe(false); + expect(parent.equals(child)).toBe(false); +}); + +test("the extension is still sealed and still refuses a bare subclass", () => { + class Sub extends PersonWithAge {} + const outcome = Sub.make({ id, name: "ada", age: 36 }).match({ + ok: () => "WRONGLY ACCEPTED", + errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), + defect: () => "defect", + }); + expect(outcome).toBe("defect"); +}); + +test("an extension can itself be extended", () => { + const Nick = z.string().min(1).brand("Nick"); + class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} + const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); + expect(d.nickname).toBe("ace"); + expect(d.age).toBe(36); + expect(d._tag).toBe("Deeper"); +}); + +test("extend carries declarations, not class-body members", () => { + // `extend` rebuilds from the field map and options — a getter written in the + // parent's class body is not part of either, so it does not come along. + // Re-declare it, or put shared behaviour in a plain function. + const Nick = z.string().min(1).brand("Nick"); + class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} + const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); + expect("isAdult" in d).toBe(false); +}); diff --git a/packages/entity/src/extend.test-d.ts b/packages/entity/src/extend.test-d.ts new file mode 100644 index 0000000..3077017 --- /dev/null +++ b/packages/entity/src/extend.test-d.ts @@ -0,0 +1,33 @@ +import { test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const Id = z.uuid().brand("Id"); +const Name = z.string().min(1).brand("Name"); +const Age = z.number().int().brand("Age"); + +class Person extends Entity("Person")({ id: Id, name: Name }) {} + +test("extend enforces the same field rules as a fresh declaration", () => { + Person.extend("Ok")({ age: Age }); + + // @ts-expect-error an unbranded field is rejected, exactly as in Entity(...) + Person.extend("Unbranded")({ plain: z.string() }); + + // @ts-expect-error a reserved name is rejected, exactly as in Entity(...) + Person.extend("Reserved")({ update: Name }); +}); + +test("an extension's instance carries both halves of the shape", () => { + class WithAge extends Person.extend("WithAge")({ age: Age }) {} + const p = WithAge.make({}).getOrThrow(); + const name: z.infer = p.name; + const age: z.infer = p.age; + const tag: "WithAge" = p._tag; + void name; + void age; + void tag; + // @ts-expect-error the extension's data is still read-only + p.age = age; +}); diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index c43b888..de7655d 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -1,7 +1,9 @@ import type { AsyncResult, Result } from "unthrown"; import type { z } from "zod"; +import type { ComputedField as ComputedFieldOf } from "./computed.js"; import type { InvalidEntity } from "./errors.js"; +import type { OnlyNominal } from "./shape.js"; export type Fields = Record; @@ -285,6 +287,35 @@ export type EntityStatic< readonly __createInput: CreateInputOf; readonly __patch: PatchOf; make(this: new (d: Sealed>) => T, state: unknown): Result; + /** + * A new entity with this one's fields plus more, under its own tag. + * + * The parent's options are inherited and merged per key, child winning, so + * an extension is never quietly laxer than what it extends. It is a fresh + * entity, not a subclass: distinct tag, distinct `equals` identity, its own + * schemas. + */ + extend( + tag: Tag2, + ): < + S2 extends Fields, + A2 extends Fields = A, + const G2 extends readonly (keyof (S & S2))[] = G, + const I2 extends readonly (keyof OutputOf)[] = I extends readonly (keyof OutputOf< + S & S2, + A2 + >)[] + ? I + : [], + >( + fields: S2 & OnlyNominal, + options?: { + readonly generated?: G2; + readonly immutable?: I2; + readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; + readonly invariants?: (d: OutputOf) => readonly string[]; + }, + ) => EntityStatic; factory( this: new (d: Sealed>) => T, generators: Generators,