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
26 changes: 26 additions & 0 deletions .changeset/entity-extend.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
34 changes: 34 additions & 0 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment thread
btravers marked this conversation as resolved.
const declarations = new WeakMap<
object,
{ readonly fields: Fields; readonly options: Record<string, unknown> | undefined }
>();

/**
* `class X extends Entity("X")({ …fields }) {}`
*
Expand Down Expand Up @@ -352,6 +362,30 @@ export function Entity<Tag extends string>(tag: Tag) {
}

attachInstance<Base & DeepReadonly<OutputShape>>(Base, input);
declarations.set(Base, { fields, options: options as Record<string, unknown> | 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<string, unknown>) => {
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<Tag, S, A, G, I>;
};
Expand Down
112 changes: 112 additions & 0 deletions packages/entity/src/extend.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Upper>) },
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<typeof Name> }).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);
});
33 changes: 33 additions & 0 deletions packages/entity/src/extend.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Name> = p.name;
const age: z.infer<typeof Age> = 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;
});
31 changes: 31 additions & 0 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Comment thread
Copilot marked this conversation as resolved.
export type Fields = Record<string, z.ZodTypeAny>;

Expand Down Expand Up @@ -285,6 +287,35 @@ export type EntityStatic<
readonly __createInput: CreateInputOf<S, G>;
readonly __patch: PatchOf<S, A, I>;
make<T>(this: new (d: Sealed<OutputOf<S, A>>) => T, state: unknown): Result<T, InvalidEntity>;
/**
* 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<Tag2 extends string>(
tag: Tag2,
): <
S2 extends Fields,
A2 extends Fields = A,
const G2 extends readonly (keyof (S & S2))[] = G,
const I2 extends readonly (keyof OutputOf<S & S2, A2>)[] = I extends readonly (keyof OutputOf<
S & S2,
A2
>)[]
? I
: [],
>(
fields: S2 & OnlyNominal<S2>,
options?: {
readonly generated?: G2;
readonly immutable?: I2;
readonly computed?: { [K in keyof A2]: ComputedFieldOf<A2[K], InputOf<S & S2>> };
readonly invariants?: (d: OutputOf<S & S2, A2>) => readonly string[];
},
) => EntityStatic<Tag2, S & S2, A2, G2, I2>;
factory<T>(
this: new (d: Sealed<OutputOf<S, A>>) => T,
generators: Generators<S, G>,
Expand Down
Loading