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
33 changes: 33 additions & 0 deletions .changeset/nest-entities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@btravstack/entity": minor
---

An entity can now declare another entity as a field, so an aggregate is itself
an entity rather than a bare `z.object(...)`:

```ts
class Order extends Entity("Order")({
id: OrderId,
customer: Customer.instance,
watchers: z.array(Customer.instance),
}) {}
```

The nested entities keep their behaviour, computed fields and `_tag`;
invariants can span the outer entity and a nested one; a nested validation
failure reports the full path; and `JSON.stringify` walks the tree to plain
data. Previously the field map rejected `Customer.instance`, so an aggregate
had to be a plain schema and lost `make`, `update`, invariants and immutability.

`instance` also now carries `_tag` in its type, matching what it has always set
at runtime.

The rejection message for a genuinely unbranded field is readable now — it
names `DomainFieldMustBeBrandedOrAnEntity` instead of a tuple TypeScript
truncated to `& [...]`.

A field may no longer take a name the entity installs on every instance —
`_tag`, `equals`, `toJSON` or `update`. Such a field used to shadow the member
silently: a field called `update` left `entity.update` holding a string, with
the method gone and no error anywhere. It is now a compile error naming
`FieldNameIsReservedByEntity`.
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ That one declaration gives you:
straight to a class instance;
- **behaviour** — the class body (`greeting` above) plus built-in
`update`/`encode`/`toJSON`/`equals`;
- **composability** — `Organization.instance` nests inside `z.object({...})`
or `z.array(...)` and parses to a real `Organization`, so an aggregate can
hold other entities without losing their behaviour.
- **composability** — `Organization.instance` is a field like any other, so an
aggregate is itself an entity rather than a bare schema, and the entities
inside it keep their behaviour.

Every fallible operation returns an
[`unthrown`](https://github.com/btravstack/unthrown) `Result<T, InvalidEntity>`
Expand Down Expand Up @@ -159,13 +159,30 @@ const ResponseBody = Organization.output;
```

`instance` is the composable surface for domain code — the only member that
produces real class instances:
produces real class instances. It is a valid **field**, so an aggregate is an
entity in its own right, with the invariants, immutability and entry points
that implies:

```ts
const Aggregate = z.object({
organization: Organization.instance,
members: z.array(Member.instance),
});
class Order extends Entity("Order")({
id: OrderId,
customer: Customer.instance,
watchers: z.array(Customer.instance),
}) {}

const order = Order.make(row).getOrThrow();
order.customer instanceof Customer; // true
order.customer.shout; // the nested entity keeps its computed fields
order.customer._tag; // and its tag, for `P.tag(...)` matching
```

An invariant can span the outer entity and a nested one, a nested failure
reports the full path (`["customer", "name"]`), and `JSON.stringify` walks the
whole tree down to plain data. It still composes into a plain `z.object(...)`
too, when what you want is a schema rather than an entity:

```ts
const Aggregate = z.object({ organization: Organization.instance });
Aggregate.parse(raw).organization instanceof Organization; // true
```

Expand Down Expand Up @@ -249,6 +266,10 @@ Person.make(person.toJSON()); // ✓ also fine — computed keys are re-derived
}
```

A field may not be named `_tag`, `equals`, `toJSON` or `update`: those are
installed on every instance, and a data field of the same name would shadow
one silently. The field map rejects them.

Both are **arrays of field names**, and both are keyed off `keyof S`
(`generated`) or `keyof output` (`immutable`), so a typo — `immutable:
["slugg"]` — is a compile error, not a silently-mutable field.
Expand Down
24 changes: 12 additions & 12 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,18 @@ class name it labels, ahead of the field map.

## Statics

| Static | Kind | Purpose |
| -------------------- | --------------- | ------------------------------------------------------------------------- |
| `entityName` | `string` | the tag passed to `Entity(tag)` |
| `input` | `ZodObject` | the full wire object |
| `output` | `ZodObject` | stored state and response body |
| `createInput` | `ZodObject` | create request — `input` minus `generated` |
| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial |
| `instance` | `ZodType` | parses straight to a class instance, for nesting entities in domain code |
| `~standard` | Standard Schema | `instance`'s Standard Schema entry point |
| `make(state)` | method | already-stored state → entity, for row mappers and event folds |
| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` |
| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` |
| Static | Kind | Purpose |
| -------------------- | --------------- | --------------------------------------------------------------------------------- |
| `entityName` | `string` | the tag passed to `Entity(tag)` |
| `input` | `ZodObject` | the full wire object |
| `output` | `ZodObject` | stored state and response body |
| `createInput` | `ZodObject` | create request — `input` minus `generated` |
| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial |
| `instance` | `ZodType` | parses straight to a class instance; valid as a field, so aggregates are entities |
| `~standard` | Standard Schema | `instance`'s Standard Schema entry point |
| `make(state)` | method | already-stored state → entity, for row mappers and event folds |
| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` |
| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` |

**Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`,
`updateInput`); domain code composes `instance`.** All four `ZodObject`s
Expand Down
113 changes: 113 additions & 0 deletions packages/entity/src/nesting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { P } from "unthrown";
import { expect, test } from "vitest";
import { z } from "zod";

import { Entity, computed } from "./index.js";

const CustomerId = z.uuid().brand("CustomerId");
const OrderId = z.uuid().brand("OrderId");
const Name = z.string().min(1).brand("Name");
const Upper = z.string().min(1).brand("Upper");
const Line = z.string().min(1).brand("Line");

class Customer extends Entity("Customer")(
{ id: CustomerId, name: Name },
{ computed: { shout: computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>) } },
) {}

/** An aggregate: an entity whose fields are other entities. */
class Order extends Entity("Order")({
id: OrderId,
customer: Customer.instance,
watchers: z.array(Customer.instance),
note: Line,
}) {}

const cid = "0199b1f4-1b1e-7000-8000-000000000001";
const cid2 = "0199b1f4-1b1e-7000-8000-000000000002";
const oid = "0199b1f4-1b1e-7000-8000-000000000003";

const raw = {
id: oid,
customer: { id: cid, name: "ada" },
watchers: [{ id: cid2, name: "grace" }],
note: "rush",
};

test("an entity can declare another entity as a field", () => {
const order = Order.make(raw).getOrThrow();
expect(order.customer).toBeInstanceOf(Customer);
expect(order.customer.name).toBe("ada");
});

test("a nested entity keeps its behaviour and its computed fields", () => {
const order = Order.make(raw).getOrThrow();
expect(order.customer.shout).toBe("ADA");
expect(order.customer._tag).toBe("Customer");
expect(order.customer.equals(Customer.make({ id: cid, name: "ada" }).getOrThrow())).toBe(true);
});

test("entities nest inside an array field too", () => {
const order = Order.make(raw).getOrThrow();
expect(order.watchers[0]).toBeInstanceOf(Customer);
expect(order.watchers[0]?.shout).toBe("GRACE");
});

test("a nested entity's own validation failure surfaces with its path", () => {
const issues = Order.make({ ...raw, customer: { id: cid, name: "" } }).match({
ok: () => [] as readonly (readonly PropertyKey[])[],
errCases: (m) =>
m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => [...(i.path ?? [])])),
defect: () => [["DEFECT"]],
});
expect(issues).toEqual([["customer", "name"]]);
});

test("JSON.stringify walks nested entities down to plain data", () => {
const order = Order.make(raw).getOrThrow();
expect(JSON.parse(JSON.stringify(order))).toEqual({
id: oid,
customer: { id: cid, name: "ada", shout: "ADA" },
watchers: [{ id: cid2, name: "grace", shout: "GRACE" }],
note: "rush",
});
});

test("a nested entity survives a round trip through make", () => {
const order = Order.make(raw).getOrThrow();
const again = Order.make(JSON.parse(JSON.stringify(order))).getOrThrow();
expect(again.customer).toBeInstanceOf(Customer);
expect(again.customer.shout).toBe("ADA");
});

test("updating a sibling field leaves the nested entity intact", () => {
const order = Order.make(raw).getOrThrow();
const updated = order.update({ note: "later" as z.infer<typeof Line> }).getOrThrow();
expect(updated.note).toBe("later");
expect(updated.customer).toBeInstanceOf(Customer);
expect(updated.customer.shout).toBe("ADA");
});

test("an invariant can span the outer entity and a nested one", () => {
class Checked extends Entity("Checked")(
{ id: OrderId, customer: Customer.instance, note: Line },
{
invariants: (d) =>
d.note.length >= d.customer.name.length
? []
: ["note must be at least as long as the name"],
},
) {}
const ok = Checked.make({ id: oid, customer: { id: cid, name: "ada" }, note: "rush" });
const bad = Checked.make({ id: oid, customer: { id: cid, name: "grace" }, note: "x" });
expect(ok.isOk()).toBe(true);
expect(bad.isErr()).toBe(true);
});

test("a nested entity is not re-frozen into uselessness", () => {
const order = Order.make(raw).getOrThrow();
// the nested entity locked its own fields; the outer freeze must not have
// stripped its prototype methods
expect(typeof order.customer.toJSON).toBe("function");
expect(order.customer.toJSON()).toEqual({ id: cid, name: "ada", shout: "ADA" });
});
35 changes: 35 additions & 0 deletions packages/entity/src/shape.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { assertType, describe, test } from "vitest";
import { z } from "zod";

import { Entity } from "./index.js";
import { shape } from "./shape.js";

describe("shape() rejects unbranded scalars", () => {
const Id = z.uuid().brand("Id");
const Name = z.string().min(1).brand("Name");
const Slug = z
.string()
.regex(/^[a-z0-9-]{3,40}$/u)
Expand Down Expand Up @@ -68,4 +70,37 @@ describe("shape() rejects unbranded scalars", () => {
const wrong: OrgId = null as unknown as z.infer<typeof UserId>;
void wrong;
});

test("another entity's `.instance` is a valid field", () => {
class Customer extends Entity("Customer")({ id: Id, name: Name }) {}
shape({ id: Id, customer: Customer.instance });
shape({ id: Id, watchers: z.array(Customer.instance) });
});

test("a nested entity keeps its behaviour and tag through the field", () => {
class Customer extends Entity("Customer")({ id: Id, name: Name }) {}
class Order extends Entity("Order")({ id: Id, customer: Customer.instance }) {}
const order = Order.make({}).getOrThrow();
const tag: "Customer" = order.customer._tag;
const name: z.infer<typeof Name> = order.customer.name;
void tag;
void name;
// @ts-expect-error a nested entity's data is still read-only
order.customer.name = name;
});

test("a field may not take a name the entity installs on every instance", () => {
// @ts-expect-error `update` would shadow the prototype method
shape({ id: Id, update: Name });
// @ts-expect-error `equals` would shadow the prototype method
shape({ id: Id, equals: Name });
// @ts-expect-error `toJSON` would shadow the projection
shape({ id: Id, toJSON: Name });
// @ts-expect-error `_tag` is the runtime tag
shape({ id: Id, _tag: Name });
});

test("names that merely resemble reserved ones are fine", () => {
shape({ id: Id, updatedAt: Name, equality: Name, tag: Name, json: Name });
});
});
59 changes: 55 additions & 4 deletions packages/entity/src/shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,69 @@ type IsNarrowLiteral<T> = T extends string

type StripUndefined<T> = T extends undefined ? never : T;

type IsNominalScalar<T> = T extends Nominal ? true : IsNarrowLiteral<T> extends true ? true : false;
/**
* Another entity, reached through its `.instance` schema.
*
* Checked structurally rather than against `BaseInstance` itself: that
* interface is generic in the entity's own shape, and there is no argument
* that matches every entity — `never` is too narrow to match any, and the
* field map has no way to name the specific one. These three members are what
* every entity instance has and nothing else in a field map does.
*/
type IsEntity<T> = T extends {
readonly toJSON: () => unknown;
readonly equals: (other: unknown) => boolean;
readonly update: (patch: never) => unknown;
}
? true
: false;

type IsNominalScalar<T> = T extends Nominal
? true
: IsEntity<T> extends true
? true
: IsNarrowLiteral<T> extends true
? true
: false;

/** Strips `undefined` (for `.optional()`) and unwraps one array level before checking. */
type IsNominalField<T> =
StripUndefined<T> extends readonly (infer Element)[]
? IsNominalScalar<StripUndefined<Element>>
: IsNominalScalar<StripUndefined<T>>;

/**
* The rejection types. Named rather than tuples of strings: a tuple prints as
* `& [...]` once TypeScript truncates, hiding the advice, whereas a name
* survives truncation and *is* the message.
*/
type DomainFieldMustBeBrandedOrAnEntity = {
readonly __domainFieldMustBeBrandedOrAnEntity: never;
};

type FieldNameIsReservedByEntity = {
readonly __fieldNameIsReservedByEntity: never;
};

/**
* Names an entity installs on every instance. A data field taking one of these
* would shadow it silently — measured: a field called `update` leaves
* `entity.update` holding a string, with the method simply gone and no error
* anywhere. Rejecting the name is the only signal available, since the clash
* is invisible at runtime.
*
* Statics (`input`, `make`, …) are deliberately absent: shadowing one takes a
* `static` declaration the author wrote themselves, so it is visible in a way
* this is not.
*/
type ReservedFieldName = "_tag" | "equals" | "toJSON" | "update";

type OnlyNominal<T extends Record<string, z.ZodTypeAny>> = {
[K in keyof T]: IsNominalField<z.infer<T[K]>> extends true
? T[K]
: ["ERROR: domain fields must be branded", K];
[K in keyof T]: K extends ReservedFieldName
? FieldNameIsReservedByEntity
: IsNominalField<z.infer<T[K]>> extends true
? T[K]
: DomainFieldMustBeBrandedOrAnEntity;
};

/** The only sanctioned way to declare a domain shape. */
Expand Down
6 changes: 4 additions & 2 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,11 @@ export type EntityStatic<
* needs the subclass's own members back must narrow explicitly (e.g.
* `instanceof`) after parsing.
*/
readonly instance: z.ZodType<BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>>>;
readonly instance: z.ZodType<
BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>> & { readonly _tag: Tag }
>;
readonly "~standard": z.ZodType<
BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>>
BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>> & { readonly _tag: Tag }
>["~standard"];
/** phantom carriers, so consumers can recover the shapes for annotations */
readonly __input: InputOf<S>;
Expand Down
Loading