From investigating a defect that occurred twice in one day — once in
examples/order-api-contract (#76) and again in a documentation sample that had
copied it (#77).
The defect
The customers fragment declared its NOT_FOUND payload with orderRef:
const orderRef = z.object({ id: z.string() }); // "which order it was about"
const customersContract = {
find: oc.…errors({ NOT_FOUND: { data: orderRef } }), // a CUSTOMER id
};
Both schemas are z.object({ id: z.string() }), so nothing separates them.
OrderRef is exported, so a client consuming the contract was told the customers
fragment answers with an order id. Fixed twice by hand, by a reader noticing.
What I checked, and what it rules out
Branding the contract boundary alone catches nothing. Verified: the
controller passes error.id from a domain error, and every domain error carries
readonly id: string. A plain string flows into any branded slot, so no
diagnostic fires.
The brand already exists and is dropped. order.ts and customer.ts define
OrderId, CustomerId, CustomerName and Quantity with z.string().brand(…),
and entities use them — id: Entity.field(OrderId, { immutable: true }). But all
7 error declarations across order.ts, customer.ts and fulfillment.ts
take a bare id: string. The brand is thrown away one line after it is minted.
Branding both sides does catch it. Verified in a scratch file: a CustomerId
in an OrderRef slot is a compile error.
The cost is asymmetric, and that decides the shape
|
what breaks |
cost |
| Error payloads and outputs |
nothing for a client — it only ever receives these, and a branded string is a string |
7 error declarations, ~21 construction sites |
| Inputs |
all 21 client call sites: client.orders.find({ id: "o-1" }) stops compiling and becomes OrderId.parse("o-1") |
high, paid by every consumer forever |
Also verified: a branded input genuinely rejects { id: "o-1" }.
Proposal: brand the errors and outputs, never the inputs
That catches this defect at the controller — which contract.ts's own comment
already calls "the one place the two are converted" — and leaves a client's
ergonomics untouched.
It also keeps the contract's stated position mostly intact. "A brand is a
compile-time fiction that does not survive serialization" stays true; the
fiction is only ever asked of the server, never of a caller.
Most of the ~21 construction sites should be free, since an entity's id is
already OrderId. The ones that cost are where an id arrives as a raw string —
which is exactly where a .parse() belongs.
Why this is not urgent
Both occurrences were caught in review. The blast radius is a mislabelled error
payload, not a wrong query. And the more dangerous version of the same confusion
is not addressed by any of this — see below.
The sharper sibling, worth its own look
Every repository and use case names its tenant positionally:
OrderRepository.find(tenantId, id)
PlaceOrder.execute(tenantId, id, quantity)
Two strings, adjacent, in that order. Swapping them type-checks today and returns
another tenant's row — or none — rather than mislabelling an error. Branding
TenantId alongside OrderId would close that, and it is the case where the
consequence is real rather than cosmetic.
Note this is the same class of problem #65 just fixed for provider dependencies
(positional deps silently rebinding when the service shapes match). This is the
same hazard one layer down, in the domain's own argument lists, and #65's fix
does not reach it.
Acceptance
- Domain errors carry branded ids; the contract's error-payload and output
schemas match them.
- Contract inputs stay unbranded, with the reason recorded next to the
decision so it is not "tidied" later.
- A type test pins that a
CustomerId cannot be passed where an OrderId is
declared — the exact defect, made a compile error.
- A decision on
TenantId, either here or in its own issue.
From investigating a defect that occurred twice in one day — once in
examples/order-api-contract(#76) and again in a documentation sample that hadcopied it (#77).
The defect
The customers fragment declared its
NOT_FOUNDpayload withorderRef:Both schemas are
z.object({ id: z.string() }), so nothing separates them.OrderRefis exported, so a client consuming the contract was told the customersfragment answers with an order id. Fixed twice by hand, by a reader noticing.
What I checked, and what it rules out
Branding the contract boundary alone catches nothing. Verified: the
controller passes
error.idfrom a domain error, and every domain error carriesreadonly id: string. A plain string flows into any branded slot, so nodiagnostic fires.
The brand already exists and is dropped.
order.tsandcustomer.tsdefineOrderId,CustomerId,CustomerNameandQuantitywithz.string().brand(…),and entities use them —
id: Entity.field(OrderId, { immutable: true }). But all7 error declarations across
order.ts,customer.tsandfulfillment.tstake a bare
id: string. The brand is thrown away one line after it is minted.Branding both sides does catch it. Verified in a scratch file: a
CustomerIdin an
OrderRefslot is a compile error.The cost is asymmetric, and that decides the shape
client.orders.find({ id: "o-1" })stops compiling and becomesOrderId.parse("o-1")Also verified: a branded input genuinely rejects
{ id: "o-1" }.Proposal: brand the errors and outputs, never the inputs
That catches this defect at the controller — which
contract.ts's own commentalready calls "the one place the two are converted" — and leaves a client's
ergonomics untouched.
It also keeps the contract's stated position mostly intact. "A brand is a
compile-time fiction that does not survive serialization" stays true; the
fiction is only ever asked of the server, never of a caller.
Most of the ~21 construction sites should be free, since an entity's
idisalready
OrderId. The ones that cost are where an id arrives as a raw string —which is exactly where a
.parse()belongs.Why this is not urgent
Both occurrences were caught in review. The blast radius is a mislabelled error
payload, not a wrong query. And the more dangerous version of the same confusion
is not addressed by any of this — see below.
The sharper sibling, worth its own look
Every repository and use case names its tenant positionally:
Two strings, adjacent, in that order. Swapping them type-checks today and returns
another tenant's row — or none — rather than mislabelling an error. Branding
TenantIdalongsideOrderIdwould close that, and it is the case where theconsequence is real rather than cosmetic.
Note this is the same class of problem #65 just fixed for provider dependencies
(positional deps silently rebinding when the service shapes match). This is the
same hazard one layer down, in the domain's own argument lists, and #65's fix
does not reach it.
Acceptance
schemas match them.
decision so it is not "tidied" later.
CustomerIdcannot be passed where anOrderIdisdeclared — the exact defect, made a compile error.
TenantId, either here or in its own issue.