From 5fa0df224ef9252cb57d292d573c48ebee8b2ee2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:32:15 +0200 Subject: [PATCH 01/10] fix(example): the HTTP contract validates its inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit type() is oRPC's escape hatch for values trusted without validation — its runtime validate returns the input unchanged — so every procedure accepted whatever a client sent and { quantity: "abc" } reached the use case typed number. The zod schemas are the source now and the TS types are inferred from them, matching order-temporal-contract and order-amqp-contract. Two specs, both mutation-checked against the contract reverted to type<>(): one that a malformed input is refused, one that the handler is never entered. The second reads the recording sink rather than the stored row, because the domain refuses "abc" too — a stored-row assertion passes either way. --- examples/order-api-contract/package.json | 3 +- examples/order-api-contract/src/contract.ts | 42 +++++++++++++-------- examples/order-api/src/api.spec.ts | 38 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/examples/order-api-contract/package.json b/examples/order-api-contract/package.json index b081090..b56bbb0 100644 --- a/examples/order-api-contract/package.json +++ b/examples/order-api-contract/package.json @@ -15,7 +15,8 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@orpc/contract": "catalog:" + "@orpc/contract": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@btravstack/contract": "workspace:*", diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 5d05c08..e493930 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -1,16 +1,26 @@ import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; /** * What an order looks like on the wire. Not the entity: `Order`'s fields are * branded (`OrderId`, `Quantity`), and a brand is a compile-time fiction that * does not survive serialization. The transport speaks its own shape, and * the orders slice's controller is the one place the two are converted. + * + * A **schema**, with the type inferred from it rather than declared beside it. + * `type()` — what this contract used before — is oRPC's escape hatch for + * "trust this without validating", so every procedure accepted whatever a + * client sent and `{ quantity: "abc" }` reached the use case typed `number`. + * One definition means the checked shape and the compiled shape cannot drift, + * which is what `order-temporal-contract` and `order-amqp-contract` already do. */ -export type OrderView = { readonly id: string; readonly quantity: number }; +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; /** The payload every declared error carries — which order it was about. */ -export type OrderRef = { readonly id: string }; +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; /** * An **unauthenticated** input names its tenant, because this API serves @@ -27,32 +37,34 @@ export type OrderRef = { readonly id: string }; * contrast is the lesson — where a caller's identity establishes the tenant, * the input has nothing to say about it. */ -export type Tenanted = { readonly tenantId: string }; +const tenanted = z.object({ tenantId: z.string() }); +export type Tenanted = z.infer; /** What a customer looks like on the wire. */ -export type CustomerView = { readonly id: string; readonly name: string }; +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; /** The customers slice's own fragment. Reached as `contract.customers`; a fragment is a contract in its own right, so the slice can be served alone. */ const customersContract = { find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(tenanted.extend({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: orderRef } }), }; /** diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 6f55ff9..da62cc1 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -335,6 +335,44 @@ describe("order-api", () => { ); }); + it("refuses a malformed input before the use case is reached", async ({ + serve, + clientFor, + api, + }) => { + // GIVEN the real composition root and a credentialed caller + const app = serve(api); + const client = await clientFor(app); + + // WHEN a procedure is called with an input the contract's schema rejects, + // past the client's own types + const refused = await client.orders.place({ id: "o-1", quantity: "abc" } as never); + + // THEN oRPC refused it before dispatch. This is the property `type()` + // did not have: it validates nothing, so `"abc"` reached the use case + // typed `number`. `BAD_REQUEST` is undeclared, so it lands on the defect + // channel like any error the contract does not model + expect(refused).toBeDefectWith( + expect.objectContaining({ constructor: ORPCError, code: "BAD_REQUEST", inferable: false }), + ); + }); + + it("never enters the handler for a malformed input", async ({ serve, clientFor, recording }) => { + // GIVEN the real graph, recording every line its logger writes + const client = await clientFor(serve(recording.api)); + + // WHEN a malformed input is sent, past the client's own types + await client.orders.place({ id: "o-rejected", quantity: "abc" } as never); + + // THEN neither the controller nor the interactor wrote a line: oRPC + // refused the input before dispatch, so the handler was never entered. The + // request-scope line still lands, because the unit opened. Asserting on + // the absence of those two rather than on the stored row, because the + // DOMAIN would refuse `"abc"` too — a test that checks nothing was stored + // passes whether or not the contract validates, and pins nothing + expect(recording.lines().map((line) => line.message)).toEqual(["request finished"]); + }); + it("serves the unmarked fragment to a caller presenting nothing", async ({ tenant, serve, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823e399..637d30d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,6 +431,9 @@ importers: '@orpc/contract': specifier: 'catalog:' version: 2.0.0-beta.28(@opentelemetry/api@1.9.1) + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@btravstack/contract': specifier: workspace:* From df252e2f6e0923e331d8368b1a00aadb57ea3a97 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:34:27 +0200 Subject: [PATCH 02/10] docs: validate the tutorial contract with zod, not type<>() --- docs/tutorial/getting-started.md | 26 +++++++++++++++----------- docs/tutorial/second-runtime.md | 4 ++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index f28ff87..25437ab 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -19,21 +19,21 @@ to stop. It takes about ten minutes. ::: code-group ```sh [pnpm] -pnpm add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +pnpm add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ```sh [npm] -npm install @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +npm install @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ```sh [yarn] -yarn add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +yarn add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ::: -Every one of those is a **peer** of `@btravstack/http`, so your application -holds a single copy of each ([why](/explanation/peer-dependencies)). The +Every one of those but `zod` is a **peer** of `@btravstack/http`, so your +application holds a single copy of each ([why](/explanation/peer-dependencies)). The project needs `"type": "module"` in its `package.json` — `main.ts` ends in a top-level `await` — TypeScript in `strict` mode, and Node `>=20`. @@ -74,18 +74,22 @@ procedure, `hello`, with a typed input and output: ```ts // contract.ts -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; export const contract = { hello: oc - .input(type<{ readonly name: string }>()) - .output(type<{ readonly message: string }>()), + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })), }; ``` -`oc` is oRPC's contract builder; `type()` declares a shape without a schema -library. A client can import this file and call the service without the -server's code — which is why it is its own file. +`oc` is oRPC's contract builder, and the schemas are **validated at the +boundary**: a client that posts `{ name: 42 }` is rejected before `hello` runs. +Reach for oRPC's `type()` only where you genuinely trust a shape without +checking it — it validates nothing, so an unchecked input arrives typed as +whatever the contract claimed. A client can import this file and call the +service without the server's code — which is why it is its own file. ## Step 4 — Implement the contract as a router diff --git a/docs/tutorial/second-runtime.md b/docs/tutorial/second-runtime.md index f18b886..0c7a2c0 100644 --- a/docs/tutorial/second-runtime.md +++ b/docs/tutorial/second-runtime.md @@ -36,8 +36,8 @@ yarn add @btravstack/temporal @temporalio/worker @temporalio/activity @temporali `@btravstack/core`, `config`, `di` and `unthrown` are already there from lesson one; the rest are `@btravstack/temporal`'s peers. `zod` is for the -contract — Temporal persists every input and output, so its contract wants a -real schema rather than a `type()` shape. +contract, the same as lesson one's — and it earns its place twice over here, +because Temporal persists every input and output and replays them later. You also need a Temporal service to poll. The [Temporal CLI](https://docs.temporal.io/cli) ships one for development: From a05490804ed2389cabaf9bc5c3edb973c29bac3e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:35:13 +0200 Subject: [PATCH 03/10] docs: validate the home page contract with zod, not type<>() --- docs/index.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index 1ad7649..35ff017 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,20 +40,25 @@ single router through ```ts import { runMain } from "@btravstack/core"; import { HttpModule, HttpRouter } from "@btravstack/http"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; import { P } from "unthrown"; +import { z } from "zod"; import { OrderApplicationModule, PlaceOrder } from "./application.js"; import { OrderPersistenceModule } from "./persistence.js"; // The contract comes first; a client can take it without the server. +// Schemas, not oRPC's `type()`: they check what arrives, not just what compiles. +const orderView = z.object({ id: z.string(), quantity: z.number() }); +const orderRef = z.object({ id: z.string() }); + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string; readonly quantity: number }>()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type<{ readonly id: string }>() }, - CONFLICT: { data: type<{ readonly id: string }>() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), }; From bbb59c1fb6107abe5a0617afd8f3470a6a4a2cc7 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:35:40 +0200 Subject: [PATCH 04/10] docs: validate the contract reference samples with zod, not type<>() --- docs/reference/contract.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/reference/contract.md b/docs/reference/contract.md index 9a3e6ea..b83ff59 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -44,20 +44,21 @@ procedure (which protects itself): ```ts import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string }>()), + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(z.object({ id: z.string() })), }; export const contract = { orders: authenticated(ordersContract), customers: { find: oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly name: string }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), }, }; ``` @@ -77,12 +78,13 @@ import { isAuthenticated, type IsMarked, } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const quote = authenticated( oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly total: number }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ total: z.number() })), ); export type QuoteIsMarked = IsMarked; // true From b2951f587f53b56fc279bdaa75b6fe3e7ac3c2bf Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:36:04 +0200 Subject: [PATCH 05/10] docs: validate the protected contract sample with zod, not type<>() --- docs/how-to/protect-a-procedure.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 8af5230..2f7959e 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -37,18 +37,19 @@ that a client should be able to read without taking the server: ```ts import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string }>()), + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(z.object({ id: z.string() })), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly name: string }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), }; export const contract = { From 45ee6dfd5334852c64e0d68e8cffd2b575fca9d0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:36:39 +0200 Subject: [PATCH 06/10] docs: validate the serve-over-http contract with zod, not type<>() --- docs/how-to/serve-orpc-over-http.md | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index b20694f..62c0a98 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -37,26 +37,36 @@ The contract lives in its own package, because a client needs it and needs none of the server: ```ts -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; -export type OrderView = { readonly id: string; readonly quantity: number }; -export type OrderRef = { readonly id: string }; +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; export const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; ``` +The shapes are **schemas**, and the types are inferred from them rather than +declared beside them: one definition, so what is checked at the boundary and +what the compiler believes cannot drift. oRPC's `type()` would declare the +same types and validate nothing — `{ quantity: "abc" }` would reach `place` +typed `number`. + ## Step 2 — the router, as a provider `HttpRouter(ordersContract)` is di's own `Provider(port)` on the starter's From 8da58412cde7eeb04c2c87e66fb1b68552278093 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:38:00 +0200 Subject: [PATCH 07/10] docs: validate the sliced contract fragments with zod, not type<>() --- .../how-to/split-a-router-into-controllers.md | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index c5d889c..37dccd3 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -25,25 +25,37 @@ positional form already takes, just smaller — and the root contract is a record of them: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(z.object({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: orderRef } }), }; export const contract = { @@ -52,9 +64,12 @@ export const contract = { }; ``` -The fragments stay module-private; `contract` is the only export, and every -consumer below reaches a fragment through it — `contract.orders`, -`contract.customers`. +The fragments stay module-private; `contract` and the view types inferred from +its schemas are the only exports, and every consumer below reaches a fragment +through it — `contract.orders`, `contract.customers`. A schema is what a +fragment is made of, not a bare `type()`: it validates what arrives at the +slice, and inferring the view type from it keeps the checked shape and the +compiled one from drifting apart. ## Step 2 — a controller per slice From a335be60fbfa7b3e03e1d3d3fd49d19f83bf0390 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:38:45 +0200 Subject: [PATCH 08/10] docs: validate the order-api example contract with zod, not type<>() --- docs/examples/order-api.md | 43 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index b210f9c..b7e9268 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -22,25 +22,34 @@ The contract splits into two fragments, `orders` and `customers`, each a `RouterContract` in its own right: ```ts +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(z.object({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: orderRef } }), }; export const contract = { @@ -49,8 +58,18 @@ export const contract = { }; ``` -The two fragments are module-private; `contract` is the package's only value -export, and every consumer reaches a fragment through it — +The wire shapes are **zod schemas**, with the view types inferred from them +rather than declared beside them. They are not the entities: `Order`'s fields +are branded (`OrderId`, `Quantity`) and a brand is a compile-time fiction that +does not survive serialization, so the transport speaks its own shape and each +slice's controller is the one place the two are converted. oRPC's `type()` +would say the same thing to the compiler and check nothing at runtime, which +is how `{ quantity: "abc" }` reaches a use case typed `number`; a schema is +what makes the boundary real, and inferring the type from it is what keeps +the checked shape and the compiled one from drifting. + +The two fragments are module-private; `contract` and the view types are the +package's exports, and every consumer reaches a fragment through it — `contract.orders`, `contract.customers`. Each slice lives under `slices//` — a `controller.ts` implementing that From 9d811a360744acf88523a30b2fd0bb38ced2d83c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:39:13 +0200 Subject: [PATCH 09/10] docs: validate the order-application contract sample with zod, not type<>() --- docs/examples/order-application.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 07ea4f3..aaef831 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -221,23 +221,35 @@ the router's exhaustive `mapErrCases`, so adding a domain error without a code stops the router compiling: ```ts +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + export const orderContract = { orders: { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }, }; ``` +All three are **schemas**, with the wire types inferred from them rather than +declared beside them — one definition, so what a procedure checks and what the +compiler believes cannot drift apart. That is why `zod` is in the list above: +oRPC's `type()` would type the same procedures and validate nothing, and an +input nobody checks arrives typed as whatever the contract claimed. + `order-temporal-contract` declares one workflow and five activities, four errors marked `nonRetryable`; `order-amqp-contract` one exchange, one event and one subscriber queue with a `retry` / dead-letter policy. Their specs From 9996f003117b428d41fc29d41bcfb70d477ffa0c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 09:22:12 +0200 Subject: [PATCH 10/10] fix(example): the customers fragment names a customer, not an order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converting to zod, I typed the customers NOT_FOUND payload with orderRef — documented as 'which order it was about'. The original used an anonymous shape precisely to avoid that, and the exported type would have lied to a client about which entity it names. It has customerRef now, same shape, its own name. The two examples/ pages also gained the imports their fences reference: those pages are fragment-style throughout, so oc was already dangling before this change, but a reader meeting z.object with no idea where z comes from is worse than a fence that carries two import lines. Both fences compiled. --- docs/examples/order-api.md | 16 ++++++++++++++-- docs/examples/order-application.md | 3 +++ examples/order-api-contract/src/contract.ts | 11 ++++++++++- examples/order-api-contract/src/index.ts | 1 + 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index b7e9268..e1c2efe 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -22,15 +22,27 @@ The contract splits into two fragments, `orders` and `customers`, each a `RouterContract` in its own right: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + const orderView = z.object({ id: z.string(), quantity: z.number() }); export type OrderView = z.infer; const orderRef = z.object({ id: z.string() }); export type OrderRef = z.infer; +// The unmarked fragment names its tenant on the input; the marked one does not, +// because a caller's identity establishes it there. +const tenanted = z.object({ tenantId: z.string() }); + const customerView = z.object({ id: z.string(), name: z.string() }); export type CustomerView = z.infer; +// Same shape as `orderRef`, deliberately not the same schema: reusing it would +// type a customer id as "which order it was about". +const customerRef = z.object({ id: z.string() }); +export type CustomerRef = z.infer; + const ordersContract = { place: oc .input(z.object({ id: z.string(), quantity: z.number() })) @@ -47,9 +59,9 @@ const ordersContract = { const customersContract = { find: oc - .input(z.object({ id: z.string() })) + .input(tenanted.extend({ id: z.string() })) .output(customerView) - .errors({ NOT_FOUND: { data: orderRef } }), + .errors({ NOT_FOUND: { data: customerRef } }), }; export const contract = { diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index aaef831..020530c 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -221,6 +221,9 @@ the router's exhaustive `mapErrCases`, so adding a domain error without a code stops the router compiling: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + const orderView = z.object({ id: z.string(), quantity: z.number() }); export type OrderView = z.infer; diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index e493930..364c887 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -44,6 +44,15 @@ export type Tenanted = z.infer; const customerView = z.object({ id: z.string(), name: z.string() }); export type CustomerView = z.infer; +/** + * What the customers fragment's `NOT_FOUND` carries. The same *shape* as + * `orderRef` and deliberately not the same schema: reusing that one would type + * a customer id as "which order it was about", and the exported type would lie + * to a client about which entity it names. + */ +const customerRef = z.object({ id: z.string() }); +export type CustomerRef = z.infer; + /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc @@ -64,7 +73,7 @@ const customersContract = { find: oc .input(tenanted.extend({ id: z.string() })) .output(customerView) - .errors({ NOT_FOUND: { data: orderRef } }), + .errors({ NOT_FOUND: { data: customerRef } }), }; /** diff --git a/examples/order-api-contract/src/index.ts b/examples/order-api-contract/src/index.ts index 2bc7d18..3769bcf 100644 --- a/examples/order-api-contract/src/index.ts +++ b/examples/order-api-contract/src/index.ts @@ -1,5 +1,6 @@ export { contract, + type CustomerRef, type CustomerView, type OrderRef, type OrderView,