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
55 changes: 43 additions & 12 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,46 @@ 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<typeof orderView>;
Comment thread
Copilot marked this conversation as resolved.

const orderRef = z.object({ id: z.string() });
export type OrderRef = z.infer<typeof orderRef>;

// 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<typeof customerView>;

// 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<typeof customerRef>;

const ordersContract = {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.input(z.object({ id: z.string(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
INVALID_QUANTITY: { data: orderRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
.input(orderRef)
.output(orderView)
.errors({ NOT_FOUND: { data: orderRef } }),
};

const customersContract = {
find: oc
.input(type<{ readonly id: string }>())
.output(type<CustomerView>())
.errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }),
.input(tenanted.extend({ id: z.string() }))
.output(customerView)
.errors({ NOT_FOUND: { data: customerRef } }),
};

export const contract = {
Expand All @@ -49,8 +70,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<T>()`
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/<name>/` — a `controller.ts` implementing that
Expand Down
29 changes: 22 additions & 7 deletions docs/examples/order-application.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,23 +221,38 @@ 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<typeof orderView>;
Comment thread
Copilot marked this conversation as resolved.

const orderRef = z.object({ id: z.string() });
export type OrderRef = z.infer<typeof orderRef>;

export const orderContract = {
orders: {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.input(z.object({ id: z.string(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
INVALID_QUANTITY: { data: orderRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
.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<T>()` 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
Expand Down
11 changes: 6 additions & 5 deletions docs/how-to/protect-a-procedure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
30 changes: 20 additions & 10 deletions docs/how-to/serve-orpc-over-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof orderView>;

const orderRef = z.object({ id: z.string() });
export type OrderRef = z.infer<typeof orderRef>;

export const ordersContract = {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.input(z.object({ id: z.string(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
INVALID_QUANTITY: { data: orderRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
.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<T>()` 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
Expand Down
41 changes: 28 additions & 13 deletions docs/how-to/split-a-router-into-controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof orderView>;

const orderRef = z.object({ id: z.string() });
export type OrderRef = z.infer<typeof orderRef>;

const customerView = z.object({ id: z.string(), name: z.string() });
export type CustomerView = z.infer<typeof customerView>;

const ordersContract = {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.input(z.object({ id: z.string(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
INVALID_QUANTITY: { data: orderRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
.input(orderRef)
.output(orderView)
.errors({ NOT_FOUND: { data: orderRef } }),
};

const customersContract = {
find: oc
.input(type<{ readonly id: string }>())
.output(type<CustomerView>())
.errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }),
.input(z.object({ id: z.string() }))
.output(customerView)
.errors({ NOT_FOUND: { data: orderRef } }),
};

export const contract = {
Expand All @@ -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<T>()`: 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

Expand Down
15 changes: 10 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`: 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 },
}),
};

Expand Down
18 changes: 10 additions & 8 deletions docs/reference/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() })),
},
};
```
Expand All @@ -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<typeof quote>; // true
Expand Down
26 changes: 15 additions & 11 deletions docs/tutorial/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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<T>()` 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<T>()` 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

Expand Down
Loading
Loading