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

**Declaration emit no longer expands the whole static surface into every consumer's `.d.ts`.**

`EntityStatic` — what `Entity(tag)(fields, options)` returns — was not exported,
so TypeScript had no name to write for it and serialised the entire static
surface structurally into any downstream package compiling with
`declaration: true`: the construct signature, all four `ZodObject`s, both zod
slots, the four phantom carriers and `make`/`extend`/`factory`, with the field
map repeated a dozen times over. A **one-field** entity emitted a 274,048-byte
declaration; it is now 240.

That expansion was two build failures, not a verbosity problem:

- a realistically wide domain enum (30 members, ordinary DDD widths) pushed the
repeated field map past the compiler's serialisation ceiling — `TS7056`,
fixable only by abandoning `z.enum` for a branded string and losing both
runtime membership validation and compile-time exhaustiveness ([#31]);
- a **branded object** field (`z.object({…}).brand("X")`) was expanded through
`DeepReadonly` until zod's module-private `$brand` symbol reached
computed-key position, where it cannot be named across a module boundary —
`TS4020` ([#32]). Branded objects now work, and stay deep-readonly; the
"model it as a nested entity instead" workaround is no longer needed.

Both surfaced only at the consuming package's build, long after `tsc --noEmit`,
the tests and everything else had gone green.

`EntityStatic` is now a top-level export, and `Entity.Static` for anyone
annotating by hand. Both regressions are pinned by the consumer fixture.

`EntityUnion` and `UnionMember` are exported for the same reason, one type
further along: an exported `const` holding an `Entity.union(...)` had no
top-level name either, so TypeScript expanded its members structurally and
reached `$brand` through any branded field — `TS4023: Exported variable 'X' has
or is using name '$brand' … but cannot be named`. Reported as the second error
in [#32], and reproduced by declaring a union over an entity with a branded
`Money` field.

**The zod peer range widens from `^4.4.0` to `^4.3.0`.** Nothing in the
implementation needed 4.4; the range was simply the version current at the
initial release. The floor is measured — the full surface typechecks, emits
declarations and passes its runtime assertions on 4.3.0. Monorepos that pin one
zod across every package no longer have to move the whole catalog, or relax the
peer locally, to adopt this ([#33]).

[#31]: https://github.com/btravstack/entity/issues/31
[#32]: https://github.com/btravstack/entity/issues/32
[#33]: https://github.com/btravstack/entity/issues/33
27 changes: 22 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +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 three passes — the main `tsc`, the
`.test-d.ts` pass, and the consumer declaration-emit pass.
`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).
Loading
Loading