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: 22 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,35 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
fallible operation returns an `unthrown` `Result<T, InvalidEntity>` instead of
throwing.

pnpm + turbo monorepo with two workspaces: the package, `packages/entity`, and
the documentation site, `docs`. Root scripts delegate to turbo; workspace
scripts are where the real commands live.
pnpm + turbo monorepo with five workspaces: the package, `packages/entity`; the
documentation site, `docs`; and three example packages under `examples/`, which
document the library and double as its declaration-emit fixtures. Root scripts
delegate to turbo; workspace scripts are where the real commands live.

## Commands

Scripts are in `package.json`; the root ones delegate to turbo. Three things
that are not derivable from there:

- **The gate CI runs, in order**: `format --check`, `lint`, `typecheck`,
`test`, `knip`, `build`. `typecheck` is four passes — the main `tsc`, the
`.test-d.ts` pass, and the consumer declaration-emit pass run **twice**: once
on the repo's TypeScript (7.0.2, the native port) and once on 5.9.3 through
the `typescript-consumer` alias. The second is not redundant. The native port
does not enforce the 5.x ceiling on serialised type length, so `TS7056` is
invisible to it — that gap shipped two declaration-emit bugs to a consumer
(#31, #32) while this repo's own gate stayed green. Consumers build with 5.x;
the gate has to as well.
`test`, `knip`, `build`. `typecheck` spans two workspaces:
`packages/entity` runs the main `tsc` plus the `.test-d.ts` pass, and
`examples/billing-domain` compiles **its own declarations twice** — once on
the repo's TypeScript (7.0.2) and once on 5.9.3 through the
`typescript-consumer` alias. That example is the fixture proving a downstream
library can build against this package; it is not decoration.
The second compiler is not redundant, though the reason is narrower than it
looks. **Both versions enforce `TS7056`; 5.9.3's threshold is simply lower.**
Measured on one entity carrying a 30-member enum, a branded timestamp and a
six-member literal union: 5.9.3 reported `TS7056`, 7.0.2 accepted the same
shape and reported only `TS4020`. Widen the entity and both report it. So a
band of realistic domain widths fails for consumers and passes here —
which is the band issues #31 and #32 shipped through.
- **A `paths` mapping is not how the emit fixture resolves the package.**
`examples/billing-domain` depends on `@btravstack/entity` as `workspace:*`
and reaches `dist/index.d.mts` through its real `exports`, the way an actual
consumer does. The deleted `packages/entity/consumer/` faked that with
`paths`.
- **A single test file runs from inside `packages/entity`**, not the root.
- **The docs site runs from inside `docs`**: `pnpm --filter ./docs dev`.
`pnpm build` at the root builds it too, since it is a workspace.
Expand Down
17 changes: 17 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ const GUIDE_SIDEBAR = [
},
];

// The runnable packages under `examples/`. Unlike every fenced block in the
// guide, that code compiles and its specs run in CI.
const EXAMPLES_SECTION = {
text: "Examples",
items: [
{ text: "Overview", link: "/examples/" },
{ text: "Billing domain", link: "/examples/billing-domain" },
{ text: "HTTP contract", link: "/examples/billing-api" },
{ text: "Persistence", link: "/examples/billing-persistence" },
],
};

// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "entity",
Expand Down Expand Up @@ -125,6 +137,7 @@ export default defineConfig({
{ text: "Explanation", link: "/explanation/why-entity" },
],
},
{ text: "Examples", link: "/examples/" },
{ text: "API", link: "/api/" },
{
text: "Changelog",
Expand All @@ -143,6 +156,10 @@ export default defineConfig({
GUIDE_SIDEBAR,
]),
),
// The examples carry the guide sidebar too, with their own section on
// top: they are walkthroughs of the same material, so a reader landing on
// one should still reach every page of the guide.
"/examples/": [EXAMPLES_SECTION, ...GUIDE_SIDEBAR],
"/api/": [
{
text: "API Reference",
Expand Down
85 changes: 85 additions & 0 deletions docs/examples/billing-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: HTTP contract example
description: Composing an entity's four plain ZodObjects into an oRPC contract and JSON Schema, with no hand-written omit lists.
---

# HTTP contract

[`examples/billing-api`](https://github.com/btravstack/entity/tree/main/examples/billing-api)
— turning an entity into request and response schemas for routes.

```sh
pnpm --filter @btravstack/entity-example-billing-api test
```

## The rule the package turns on

> **Contracts compose the four plain `ZodObject`s; domain code composes the
> class.**

```ts
export const CreateOrganizationBody = Organization.createInput;
export const UpdateOrganizationBody = Organization.updateInput;
export const OrganizationResponse = Organization.output;
```

There is nothing to maintain here. `createInput` is the field map minus whatever
the entity declares `generated`; `updateInput` is it minus `immutable` and minus
the computed fields, every remaining key optional. Add a generated field to the
entity and the create body follows on its own — that is the omit list nobody had
to write, and the spec asserts it by checking the generated JSON Schema has
exactly `name` and `slug`.

They are ordinary `ZodObject`s, so the usual combinators work:

```ts
export const OrganizationSummary = Organization.output.pick({
id: true,
slug: true,
});
export const OrganizationListing = z.object({
items: z.array(Organization.output),
total: z.number().int(),
});
```

## Both directions

```ts
const converter = new ZodToJsonSchemaConverter();
converter.convert(Organization.createInput, "input");
converter.convert(Organization.output, "output");
```

Or through zod directly, with `z.toJSONSchema(…, { io: "input" | "output" })`.

## And the class, deliberately, does not

```ts
z.toJSONSchema(Organization, { io: "output" }); // throws, by design
```

The class carries a `.transform()` — it parses to an _instance_, not to plain
data — and a transforming schema has no output representation. That is the whole
reason the four plain `ZodObject`s exist separately, and the example's spec pins
it in both directions: the four convert, the class throws.

## One detail worth copying

The JSON Schema exports carry an explicit `JsonSchema` annotation:

```ts
export const createOrganizationSchema: JsonSchema = jsonSchemaOf(
CreateOrganizationBody,
"input",
);
```

Without it TypeScript infers a type it cannot **name** from outside the package,
and any consumer emitting declarations fails with `TS2883` — _"cannot be named
without a reference to 'JsonSchema' … this is likely not portable"_. It is the
same class of problem as [#31](https://github.com/btravstack/entity/issues/31)
and [#32](https://github.com/btravstack/entity/issues/32), met from the other
side of the boundary, and the cure is the same: give the type a name.

Related how-to: [Expose an HTTP contract](/how-to/http-contract).
149 changes: 149 additions & 0 deletions docs/examples/billing-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---
title: Billing domain example
description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, unions and factories — in a runnable package.
---

# Billing domain

[`examples/billing-domain`](https://github.com/btravstack/entity/tree/main/examples/billing-domain)
— the modelling half: two entities and the vocabulary they are built from.

```sh
pnpm --filter @btravstack/entity-example-billing-domain test
```

## The vocabulary comes first

```ts
export const OrganizationId = z.uuid().brand("OrganizationId");
export const Slug = z.string().min(1).max(40).brand("Slug");
export const Instant = z.iso.datetime().brand("Instant");
```

Every data field is branded, and a bare `z.string()` is a **compile error**.
That is the guard, not an inconvenience: an `OrganizationId` and a `Slug` are
both strings at runtime, and nothing except a brand stops you passing one where
the other belongs.

`Money` is branded too, but it is an _object_:

```ts
export const Money = z
.object({ amount: z.number().int(), currency: Currency })
.brand("Money");
```

A value object — no identity, so it is branded rather than made an entity.
Amounts are integer minor units because binary floats are the wrong tool for
money. Minting one takes `Money.parse({ … })`; a plain object literal does not
satisfy the branded type, which is exactly the point.

## The entity

```ts
export class Organization extends Entity("Organization")(
{ id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
displayLabel: Entity.computed(
DisplayLabel,
(d) => `${d.name} (${d.slug})` as z.infer<typeof DisplayLabel>,
),
},
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
],
},
) {
get isSelfTitled(): boolean {
return this.name.toLowerCase().startsWith(this.slug.toLowerCase());
}
}
```

`generated` names what the domain produces rather than the caller, so those
fields drop out of `createInput`. `immutable` names what `update` refuses.
`computed` is re-derived on **every** construction path, so it cannot drift from
its sources — the spec checks that by renaming an organization and asserting the
label followed.

Behaviour lives in the class body. This is a real class, not a record with
functions bolted beside it.

## Nesting, and the factory

`Invoice.issuedTo` is an `Organization` used directly as a field. The class is
itself a zod schema, so it parses back to a real instance:

```ts
const rehydrated = Invoice.make(invoice.toJSON()).getOrThrow();
rehydrated.issuedTo instanceof Organization; // true
```

The package reads no clock and generates no id, so a factory is where those come
in — bound once, at the composition root:

```ts
export const createOrganization = Organization.factory({
id: () => crypto.randomUUID() as z.infer<typeof OrganizationId>,
createdAt: () => new Date().toISOString() as z.infer<typeof Instant>,
});
```

That is what leaves the entities trivially testable: nothing inside them reaches
for ambient state.

## Two things in this package that look odd on purpose

**`DunningReason` has thirty members.** Vocabularies that wide are ordinary in
billing, and this one is held at full width because it pins
[#31](https://github.com/btravstack/entity/issues/31). `TS7056` is a threshold
on serialised _characters_, so trimming the enum puts the example back under the
ceiling, where it compiles and guards nothing.

**`src/emit-guards.ts` is not example code.** It carries the assertions that
have no runtime moment — construction staying sealed, a construction key that
cannot be forged structurally, every `Entity.*` namespace member named so
declaration emit walks it. An **unused** `@ts-expect-error` in that file is a
failure rather than noise, because a namespace member emitted as a circular
self-alias still compiles and simply degenerates.

## The union discriminates data, not instances

```ts
export const BillingDocument = Entity.union("kind", [
Invoice,
CreditNote,
] as const);
```

`kind` is a **declared domain field** — `z.literal("INVOICE")` on one member and
`z.literal("CREDIT_NOTE")` on the other, both `generated` so no caller can supply
the wrong one.

It is tempting to reach for `_tag` here, since every entity has one. That does
not work, and fails quietly rather than loudly: `_tag` is non-enumerable, so it
is absent from `toJSON()` and from anything that has been through JSON. A union
built on it registers no members and rejects every payload with

```
Invalid discriminant undefined; expected one of
```

— an empty set. This example shipped that exact bug for one commit, because the
spec never called `make()` through the union. The specs now do, which is the
only reason it is not still there.

The two mechanisms are complementary, not alternatives:

| | Discriminates | Use |
| ---------------- | -------------------------------------- | ------------------------- |
| A declared field | **data** arriving from a wire or a row | `Entity.union("kind", …)` |
| `_tag` | an **instance** you already hold | `P.tag("Invoice")` |

Related reference: [Declaring an entity](/reference/declaration).
77 changes: 77 additions & 0 deletions docs/examples/billing-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: Persistence example
description: Storing and rehydrating entities — toJSON() out, make() back, with a corrupt row arriving as a Result rather than a throw.
---

# Persistence

[`examples/billing-persistence`](https://github.com/btravstack/entity/tree/main/examples/billing-persistence)
— an entity out to a row, and a row back to an entity.

```sh
pnpm --filter @btravstack/entity-example-billing-persistence test
```

## The round trip

```ts
save(organization: Organization): void {
this.#rows.set(organization.id, organization.toJSON());
}
```

`toJSON()` is the only projection the package offers, and it **is** the stored
shape. No mapper to keep in sync, and `_tag` never reaches a row — it is a
non-enumerable instance property, so it survives neither `JSON.stringify` nor a
spread. The spec asserts that explicitly, because it is the sort of thing that
starts leaking quietly.

```ts
byId(id): Result<Organization, InvalidEntity | OrganizationNotFound> {
const row = this.#rows.get(id);
if (row === undefined) return Err(new OrganizationNotFound());
return Organization.make(row);
}
```

`make()` validates on the way in. Rows outlive models — a column dropped two
migrations ago is still sitting in production — so the boundary where old data
becomes a live object is exactly where a check belongs.

What comes back is a real instance, behaviour included, not a bag of data. The
spec checks that by calling a getter on a rehydrated entity.

## Two errors, not one

A missing row and a **corrupt** row are different facts: the first is a 404, the
second is data worth paging someone about. Folding them into one error discards
the only thing that separates them.

The library defines no `NotFound` on purpose — whether an absent row is
exceptional is a repository's decision, not an entity's — so the example models
it with `unthrown`'s `TaggedError` and discriminates the two with an exhaustive
matcher:

```ts
loaded.match({
ok: (organization) => organization,
errCases: (m) =>
m
.with(P.tag("InvalidEntity"), () => 422)
.with(P.tag("OrganizationNotFound"), () => 404),
defect: () => 500,
});
```

Because the matcher is exhaustive, the day this repository grows a third error
those call sites stop compiling until someone decides what to do about it.

There is no `try`/`catch` anywhere in the file.

## Swapping the store

The store is a `Map`. Replace it with a driver and nothing else in the file
changes shape — which is the point of the entity knowing nothing about
persistence in the first place.

Related how-to: [Persist and rehydrate](/how-to/persist-and-rehydrate).
Loading