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

Producer callbacks are now typed as their schema's **input**, so the cast they
all carried is gone:

```ts
// before
shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>),
id: () => crypto.randomUUID() as z.infer<typeof OrgId>,

// after
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
id: () => crypto.randomUUID(),
```

Nothing changes at runtime: a computed value was always parsed by its own schema
on every construction path, and generated values always went through `make`'s
validation. The types now say so. Existing code compiles unchanged — a branded
return still assigns to its schema's input.

One narrowing: a generator for a field that is both `.optional()` and
`generated` was an optional key and is now required (it may return `undefined`).
Declaring that combination is not known to occur anywhere.
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ Its build is `typedoc && vitepress build`. TypeDoc reads
which is git-ignored and regenerated every build — `docs/api/index.md` is the
one hand-written page under `api/`.

**An em dash in a heading you link to ships a dead anchor, and the build stays
green.** VitePress's dead-link check does not validate anchors, and its
slugifier replaces ASCII punctuation but passes U+2014 through — measured:
`### Everywhere else, parse — through a mint helper` emitted
`id="everywhere-else-parse-—-through-a-mint-helper"`, which no natural link
spelling matches. Keep em dashes out of linked headings.

TypeDoc runs from **`docs/`** rather than from `packages/entity/` (where the
other btravstack repos put it), with its own TypeScript from the named
`typedoc` catalog. That is forced, not stylistic: the default catalog's
Expand Down
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ class Organization extends Entity("Organization")(
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
},
invariants: [
Entity.invariant(
Expand Down
11 changes: 4 additions & 7 deletions docs/examples/billing-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class Organization extends Entity("Organization")(
computed: {
displayLabel: Entity.computed(
DisplayLabel,
(d) => `${d.name} (${d.slug})` as z.infer<typeof DisplayLabel>,
(d) => `${d.name} (${d.slug})`,
),
},
invariants: [
Expand Down Expand Up @@ -98,10 +98,7 @@ export abstract class BillingDocumentBase extends Entity.abstract(
generated: ["issuedAt"],
immutable: ["issuedAt", "issuedTo"],
computed: {
period: Entity.computed(
AccountingPeriod,
(d) => d.issuedAt.slice(0, 7) as z.infer<typeof AccountingPeriod>,
),
period: Entity.computed(AccountingPeriod, (d) => d.issuedAt.slice(0, 7)),
},
invariants: [
Entity.invariant(
Expand Down Expand Up @@ -170,8 +167,8 @@ 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>,
id: () => crypto.randomUUID(),
createdAt: () => new Date().toISOString(),
});
```

Expand Down
79 changes: 59 additions & 20 deletions docs/explanation/branded-fields.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Branded fields
description: Why every field must be nominal, what counts as nominal, why the compile error is a type name, and the two blessed ways to mint a branded value.
description: Why every field must be nominal, what counts as nominal, why the compile error is a type name, and where a branded value actually has to be minted.
---

# Branded fields
Expand Down Expand Up @@ -76,39 +76,78 @@ trick: `new SomeEntity(...)` fails on a missing property called
`__useMakeOrFactoryInstead`
([Sealed construction](/explanation/sealed-construction)).

## The cost, and the two blessed patterns
## The cost, and where it falls

The cost is ceremony: a branded type has no literal syntax, so somewhere a
plain value has to become a branded one. There are exactly two honest ways.
plain value has to become a branded one. The question is where — and the
answer is narrower than it first looks, because the two places you would most
expect to pay are the two that cost nothing.

**At a boundary, parse.** The schema is the brand's gatekeeper, so crossing
from untrusted to trusted goes through it — and for entity fields that
boundary already exists: `make` takes `unknown` and validates every field, so
a database row or request body never needs pre-branded values.
### Producers pay nothing

```ts
const slug = Slug.parse(raw); // z.infer<typeof Slug> — or safeParse, handled
```
A factory generator and a `computed` derivation both hand a value to a
schema, not to a caller. A generated field is spread into `make`, which
validates it like any other data; a computed field's output is checked against
its own schema on every construction path. The parse already happens, and it
happens after the callback returns.

**Where the value is locally proven, cast.** Inside a generator or a
`computed` derivation the value is constructed in place and its validity is
visible in the same expression — and the package keeps the cast honest:
a factory's output goes through `make`'s validation, and a computed field's
output is checked against its own schema on every construction.
So both positions are typed as the schema's **input**, not its branded output.
A plain expression is already the right type:

```ts
const createOrg = Organization.factory({
id: () => crypto.randomUUID() as z.infer<typeof OrgId>,
id: () => crypto.randomUUID(),
});

computed: {
shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>),
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
}
```

An `as` anywhere else — deep in application code, on a value that came from
outside — is not minting a brand, it is forging one: it silences the exact
check the rule exists to run.
Both callbacks once ended in a cast — ~~`crypto.randomUUID() as z.infer<typeof
OrgId>`~~ — that the parse immediately re-proved. Demanding the branded form
there bought nothing: a cast cannot make a value valid, only make its author
assert that it is. Dropping it weakens no check, because the check was never
the cast. A wrong _type_ still fails to compile; a wrong _value_ still fails to
parse. (Code still carrying the old cast compiles unchanged — a branded value
assigns to its own unbranded input.)

### Everywhere else, parse through a mint helper

Outside those two positions the brand has to be minted, and the schema is its
only gatekeeper. Crossing from untrusted to trusted therefore goes through the
schema, and for entity fields that crossing already exists: `make` takes
`unknown` and validates every field, so a database row or a request body never
needs pre-branded values at all.

What is left is the code that writes values by hand — fixtures, seeds,
literals in a test — where a brand really does have to be minted one call at a
time. The spelling that keeps that readable is a helper declared beside the
vocabulary:

```ts
const slug = (value: string) => Slug.parse(value);
const name = (value: string) => DisplayName.parse(value);
const money = (amount: number, currency: "EUR" | "USD" | "GBP") =>
Money.parse({ amount, currency });
```

`slug("acme")` then reads like the literal it replaces, and the schema stays
the only thing that decides whether the value is a `Slug`. A helper is a named
parse, not a cast: it can fail.

Failing is also the one thing to know before reaching for one, because a helper
**throws**. That makes it right where a violation would be a bug in the code
that wrote it — a fixture, a seed, a literal — and wrong on anything that came
from outside the program. Untrusted data has its own entry point, and that one
returns a `Result` rather than throwing: `make`'s job, not a helper's.

A cast reaches the same shape without any of that. On a literal you wrote two
lines up it is merely unchecked; on a value that came from outside — a field
plucked off a response body, a string threaded through three functions — it is
not minting a brand but forging one, silencing the exact check the rule exists
to run. The helper costs one line and never has to be re-audited for which of
those two it is.

## Related

Expand Down
5 changes: 1 addition & 4 deletions docs/how-to/model-an-aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ class Customer extends Entity("Customer")(
{ id: CustomerId, name: Name },
{
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
},
},
) {}
Expand Down
26 changes: 26 additions & 0 deletions docs/how-to/persist-and-rehydrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ unchanged:
Organization.make(org.toJSON()); // ✓ round-trips
```

## Check a hand-built row at the call site

`make` takes `unknown`, so a row you assemble yourself gets no compile-time
check. `satisfies Entity.Input<…>` restores it:

```ts
const orgId = (value: string) => OrgId.parse(value);
const slug = (value: string) => Slug.parse(value);

const seedRow = {
id: orgId("0199b1f4-1b1e-7000-8000-000000000000"),
slug: slug("acme"),
} satisfies Entity.Input<typeof Organization>;

const seeded = Organization.make(seedRow).getOrThrow();
```

A key the entity does not declare is now a compile error rather than a value
`make` quietly ignores.

Use it where you write the row — a seed, a migration, a fixture. A driver
handing you `unknown` needs nothing: there is no literal to check, and `make`
validates it either way. The values have to be branded, which is what the mint
helpers are for
([Branded fields](/explanation/branded-fields#everywhere-else-parse-through-a-mint-helper)).

## Computed columns heal themselves

Store computed fields if you need to index or query them — `toJSON()` includes
Expand Down
55 changes: 42 additions & 13 deletions docs/how-to/test-domain-logic.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,67 @@ description: Deterministic entity tests without stubbing Date.now or crypto.rand
**Problem:** entities involve ids and timestamps, and you want deterministic
tests without stubbing `Date.now` or `crypto.randomUUID`.

> Snippets below assume these imports:
> Snippets below run in a vitest test file — `test` and `expect` in scope —
> plus:
>
> ```ts
> import { z } from "zod";
> import { P } from "unthrown";
> import { Entity } from "@btravstack/entity";
> ```
>
> Branded vocabulary (`Slug`, `Organization`, `createOrg`, …) is whatever your
> own domain declares; the sections below build the helpers from it.

## Mint fixture values with a helper, not a cast

Every field is branded, so a bare `"acme"` is not a `Slug` and a test that
passes one does not compile. Declare one helper per piece of vocabulary, beside
the vocabulary, and the rest of the file reads like literals:

```ts
const slug = (value: string) => Slug.parse(value);
const name = (value: string) => DisplayName.parse(value);
const money = (amount: number, currency: "EUR" | "USD" | "GBP") =>
Money.parse({ amount, currency });

// createOrg is the factory bound in the next section
createOrg({ slug: "acme", name: "Acme" }); // ✗ compile error — not branded
createOrg({ slug: slug("acme"), name: name("Acme") }); // ✓
```

A helper is a named `parse`, not a cast — an invalid fixture fails loudly
instead of being asserted into existence. It throws, which is what you want
here: a bad literal in a test is a bug in the test. Untrusted data goes through
`make` instead and comes back as a `Result`.

One thing worth knowing while you read a test file: `vitest` transpiles without
type-checking, so a branding violation is invisible to `vitest run`. Only `tsc`
sees it — which is why this package's own example compiles its declarations.

## Bind fixed generators instead of stubbing globals

The entity generates nothing itself, so a test binds its own sources:

```ts
const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000" as z.infer<
typeof OrgId
>;
const FIXED_AT = "2026-08-06T09:00:00Z" as z.infer<typeof Instant>;
const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000";
const FIXED_AT = "2026-08-06T09:00:00Z";

const createOrg = Organization.factory({
id: () => FIXED_ID,
createdAt: () => FIXED_AT,
});

test("a new organization starts on its trial", () => {
const org = createOrg({ slug, name }).getOrThrow();
const org = createOrg({
slug: slug("acme"),
name: name("Acme"),
}).getOrThrow();
expect(org.createdAt).toBe(FIXED_AT);
});
```

The generators need no cast: a generated value is spread into `make` and
validated there, so each one is typed as its schema's input.

No global patching, no module mocking, no reset in `afterEach`. Production
binds the same factory to real ports at the composition root.

Expand All @@ -45,10 +77,7 @@ Need distinct ids across a test? Generators are called once per create:
```ts
let n = 0;
const createOrg = Organization.factory({
id: () =>
`0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}` as z.infer<
typeof OrgId
>,
id: () => `0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}`,
createdAt: () => FIXED_AT,
});
```
Expand Down Expand Up @@ -129,4 +158,4 @@ guarantee is lost.
One trap worth knowing: do not use `as never` for the values in such a test.
`never` is assignable to _anything_, including a function type, so an assertion
written with it can silently stop testing what you meant. Use real branded
values.
values — the mint helpers above are exactly what they are for.
5 changes: 1 addition & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ class Organization extends Entity("Organization")(
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
},
invariants: [
Entity.invariant(
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/declaration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ One derived field: its schema, and the function producing it.

```ts
computed: {
fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}` as z.infer<typeof FullName>),
initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}` as z.infer<typeof Initials>),
fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}`),
initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}`),
}
```

Expand Down
Loading
Loading