Cart, contract-first
In chapter 2 you built a typed catalog around a contract
that was largely generated from Prisma. Now you design a domain that does not exist yet — a
shopping cart — the other way round: contract first, then everything else. You write the cart's
procedures and schemas as the single source of truth, then derive a fully typed client from them
without a codegen step. This is the discipline that makes the checkout saga in the next chapter safe
to build.
- 1 · Scaffold
- 2 · Catalog service
- 3 · Cart contracts
- 4 · Checkout saga
- 5 · Shipping webhook
- 6 · Deploy
What you will build
You will add a new cart contract under contracts/versions/v1/ with list / getById / create
/ update procedures, Zod schemas for a cart and its line items, and typed errors inherited from
baseContract. Then you will derive a typed @orpc/client from that contract and call it — proving
that the contract alone, with no implementation written yet, is enough to give a client end-to-end
type safety.
Before you begin
You should have finished chapter 2, so:
my-shop/has a workingproductsservice on:3001and acontracts/versions/v1/directory withproducts.contract.tsin it.aspire startis up (the dashboard answers at http://localhost:18888).
Confirm the contracts workspace is where you left it:
ls contracts/versions/v1/
You should see products.contract.ts among the files. You will add cart.contract.ts next to it.
Step 1 — Define the cart schemas
A cart holds line items, each referencing a product by id with a quantity, plus a status. Author the
Zod schemas that describe it. Open a new file contracts/versions/v1/cart.contract.ts:
// contracts/versions/v1/cart.contract.ts
import { z } from 'zod';
import { implement } from '@orpc/server';
import { baseContract } from '../../shared.ts';
import {
IdQuerySchema,
nonNegativeInt,
OffsetPaginationQuerySchema,
paginationLimit,
paginationOffset,
positiveInt,
} from '@shared/utils';
// A single line in a cart.
export const CartItemSchemaV1 = z.object({
productId: positiveInt({ description: 'Product being added' }),
quantity: z.number().int().positive().describe('How many'),
});
// The cart status lifecycle.
export const CartStatusSchemaV1 = z.enum(['open', 'checking_out', 'ordered', 'abandoned']);
// A cart, as returned by the API.
export const CartSchemaV1 = z.object({
id: positiveInt({ description: 'Cart ID' }),
customerId: z.string().min(1).describe('Owner of the cart'),
status: CartStatusSchemaV1,
items: z.array(CartItemSchemaV1).describe('Line items'),
total: z.number().nonnegative().describe('Computed cart total'),
createdAt: z.string().datetime(),
});
These hand-authored schemas play the same role the generated @database/zod schemas played for
products: they are both the runtime validators and the TypeScript types every consumer derives.
Step 2 — Declare the procedures
Now declare the cart's routes. Each is built from baseContract, so it inherits the typed errors
(NOT_FOUND, VALIDATION_ERROR, …) you met in chapter 2 — exactly as the playground's orders
contract does. Add this to the same file:
// contracts/versions/v1/cart.contract.ts (continued)
export const CreateCartSchemaV1 = z.object({
customerId: z.string().min(1).describe('Owner of the cart'),
items: z.array(CartItemSchemaV1).describe('Initial line items'),
});
export const UpdateCartSchemaV1 = z.object({
id: positiveInt({ description: 'Cart ID to update' }),
status: CartStatusSchemaV1.optional(),
items: z.array(CartItemSchemaV1).optional().describe('Replaces existing items'),
});
export const cartContract = {
// List carts with pagination.
list: baseContract
.route({ method: 'GET' })
.input(OffsetPaginationQuerySchema.extend({ status: CartStatusSchemaV1.optional() }))
.output(z.object({
items: z.array(CartSchemaV1),
total: nonNegativeInt({ description: 'Total count' }),
limit: paginationLimit({ description: 'Results per page' }),
offset: paginationOffset({ description: 'Current offset' }),
hasMore: z.boolean(),
})),
// Fetch one cart. @throws NOT_FOUND when the id is unknown.
getById: baseContract.route({ method: 'GET' }).input(IdQuerySchema).output(CartSchemaV1),
// Create a cart. @throws VALIDATION_ERROR when input is invalid.
create: baseContract.route({ method: 'POST' }).input(CreateCartSchemaV1).output(CartSchemaV1),
// Update status or items. @throws NOT_FOUND, VALIDATION_ERROR.
update: baseContract.route({ method: 'PUT' }).input(UpdateCartSchemaV1).output(CartSchemaV1),
};
// implement() makes the contract `.handler()`-bindable, just like products.
export const cartContractV1 = implement(cartContract);
The shape is intentionally the same as products and orders: a paginated list, a getById that
can throw NOT_FOUND, a create that can throw VALIDATION_ERROR, and an update. Reusing the
shape means anyone who has read one NetScript contract can read this one.
| Name | Type | Description |
|---|---|---|
list |
GET → paginated CartSchemaV1[] |
List carts, optionally filtered by status. Returns items + total + pagination metadata. |
getById |
GET → CartSchemaV1 |
Fetch one cart by id. Throws the typed NOT_FOUND error when the id is unknown. |
create |
POST → CartSchemaV1 |
Open a new cart for a customer with initial items. Throws VALIDATION_ERROR on bad input. |
update |
PUT → CartSchemaV1 |
Change cart status or replace items. The checkout saga (chapter 4) flips status to checking_out. |
Step 3 — Register the contract in the version map
A contract file is only reachable once it is wired into the versioned contract index that
@my-shop/contracts exports. Open the v1 index and add the cart alongside products:
// contracts/versions/v1/index.ts
import { productsContractV1 } from './products.contract.ts';
import { cartContractV1 } from './cart.contract.ts';
export const v1 = {
products: productsContractV1,
cart: cartContractV1,
};
Now @my-shop/contracts exposes v1.cart.* to both services and clients.
Step 4 — Derive a typed client
Here is the payoff of contract-first: a client needs only the contract to be fully typed — no running server, no generated SDK, no hand-written request types. Create a small script to prove it:
// scripts/cart-client.ts
import { createORPCClient } from '@orpc/client';
import { OpenAPILink } from '@orpc/openapi-client/fetch';
import type { cartContract } from '@my-shop/contracts/versions/v1/cart.contract.ts';
// The client's type comes entirely from the contract.
const link = new OpenAPILink(cartContract, { url: 'http://localhost:3001/api/v1' });
const client = createORPCClient<typeof cartContract>(link);
// `created` is typed as CartSchemaV1 — the editor knows its fields before you run anything.
const created = await client.create({
customerId: 'cust_1001',
items: [{ productId: 1, quantity: 2 }],
});
console.log(created.status, created.total);
Even with no cart service implemented yet, your editor types created.status as the
CartStatusSchemaV1 union and created.items as CartItemSchemaV1[]. Pass the wrong shape to
client.create(...) and it is a compile error — the contract is enforcing the boundary from both
sides.
Verify your progress
The contract is code, so the real verification is that it type-checks and that the typed client compiles against it. From the workspace root:
deno task check
A clean check proves the cart schemas, routes, and the version-map registration all line up, and that
the typed client in scripts/cart-client.ts is consistent with cartContract.
- [ ]
contracts/versions/v1/cart.contract.tsexists withlist/getById/create/update. - [ ] Every route is built from
baseContract, so it carries the shared typed errors. - [ ]
cartContractV1is registered in the v1 index and reachable asv1.cart. - [ ] The typed client in
scripts/cart-client.tscompiles —createdis typed without a running server. - [ ]
deno task checkpasses.
What you built
- A brand-new
cartdomain defined contract-first — schemas, alist/getById/create/updateprocedure set, andbaseContracttyped errors — modeled on the playground'sorderscontract. - The contract registered in the
@my-shop/contractsversion map asv1.cart. - A typed
@orpc/clientderived from the contract alone, fully type-locked with no codegen.
You now have a cart whose every interaction is described by a contract. In the next chapter that contract becomes the input to the riskiest part of any shop — checkout — which you make reliable with a durable saga.