Contracts & type flow
This page explains why NetScript is contracts-first and how a single type definition
travels from a contract, through a service handler, all the way to a typed client and a UI
island — with no second source of truth and no code-generation step to drift. It is
understanding-oriented: read it to build a mental model. When you want exact signatures,
follow the links to reference/contracts/ and
reference/service/; when you want to build the thing, follow the
capability hub for services or the
Build a service tutorial.
The thesis: the contract is the product
Most backend stacks have two sources of truth for the boundary between a server and its
callers: the server's runtime validation, and a separately-maintained client (a generated
SDK, a hand-written fetch wrapper, an OpenAPI document, a GraphQL schema). The two drift.
You change the server, forget the client, and the mismatch surfaces at runtime — in
production, as a 500 or a silently-wrong field — instead of at your desk, as a red squiggle.
NetScript removes the second source of truth. A contract is one TypeScript value that declares a route's method, its input shape, and its output shape. The server implements that exact value; the client is derived from that exact value. There is nothing to regenerate and nothing to keep in sync, because there is only ever one definition. The contract is not documentation about the boundary — it is the boundary.
What a contract actually is
A contract is built from @orpc/contract plus
zod. oc.route({ method }) declares the transport verb;
.input(...) and .output(...) attach zod schemas that describe the request and response.
The result is an inert definition — it owns no handler, opens no socket, and does no
runtime work. It is pure shape. That inertness is the point: because a contract performs no
side effects, it can be imported anywhere — by the server, by the client, by a test, by a
codegen-free tool — without dragging runtime behavior along with it.
import { z } from 'zod';
import { oc } from '@orpc/contract';
import { implement } from '@orpc/server';
// 1. zod schemas describe the data — the single shape definition.
export const UsersListItemSchemaV1 = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
summary: z.string().min(1),
status: UsersStatusSchemaV1,
createdAt: z.string().datetime(),
});
// 2. The contract binds method + input + output. No handler yet.
export const UsersContractV1 = {
health: {
check: oc.route({ method: 'GET' })
.input(z.object({}).optional())
.output(UsersHealthSchemaV1),
},
list: oc.route({ method: 'POST' })
.input(UsersListInputSchemaV1)
.output(UsersListResponseSchemaV1),
updateStatus: oc.route({ method: 'POST' })
.input(UsersUpdateStatusInputSchemaV1)
.output(UsersUpdateStatusResponseSchemaV1),
};
// 3. implement() turns the contract into a .handler()-bindable object.
export const UsersV1 = implement(UsersContractV1);
schema -> contract -> implement() -> handler -> client
| | | | |
zod oc.route binds the your code derived,
shape (verb + contract to runs not written
io) a server obj the logic by hand
Everything downstream is TYPED FROM step 1. You define the
shape once; the compiler propagates it to every consumer.
The contract version above lives under contracts/versions/v1/ and is exported through the
workspace's @<project>/contracts alias (for the scaffolded users example, that is
@my-app/contracts). Versioning the contract directory — versions/v1/, later v2/ — is
deliberate: a contract is a long-lived promise, so its breaking changes are an explicit new
version rather than an in-place mutation that silently breaks callers.
implement(): from shape to a bindable server object
implement() (from @orpc/server) is the hinge between the definition and the runtime.
Given a contract, it returns an object whose every route exposes a .handler(...) method.
The handler you pass in is type-locked to the contract: its argument is the contract's
input type, and its return value must satisfy the contract's output type. You cannot
return the wrong shape — it will not compile.
import { type UsersListItemV1, v1 } from '@my-app/contracts';
// `input` is typed from the contract's .input() schema.
// The returned object is checked against the .output() schema.
export const UsersV1 = {
list: v1.users.list.handler(async ({ input }) => {
// `input` is fully typed. The compiler knows its fields.
// Returns seeded in-memory records at this scaffold step (no DB yet).
return { items: seededUsers, pagination: { total: seededUsers.length } };
}),
updateStatus: v1.users.updateStatus.handler(async ({ input }) => {
// mutate + return a value the contract's output schema accepts
return { updated: true, id: input.id, status: input.status };
}),
};
// The router aggregates versioned handler objects into one tree.
import { UsersV1 } from './routers/v1.ts';
import { health } from './routers/health.ts';
export const v1 = { users: { ...UsersV1, health } };
export const router = { v1 };
The router tree mirrors the contract tree. Because the contract is a plain nested object —
{ v1: { users: { list, updateStatus, health } } } — the handlers nest the same way, and the
derived client later walks the same path (client.users.list(...)). There is no separate
routing table to register, no decorator to remember, and no string key that can fall out of
sync with the handler it names. The shape of your API is the shape of these objects.
Serving the router: where the contract meets HTTP
A local service hands the router to defineService(...), which wires CORS, request logging,
OpenAPI, RPC, and health endpoints in one call and binds a port. The contract's shapes become
the service's validation and its published OpenAPI document at the same time — one definition,
two artifacts.
import { defineService } from '@netscript/service';
import { router } from './router.ts';
await defineService(router, {
name: 'users',
version: '1.0.0',
port: parseInt(Deno.env.get('PORT') || '3001'),
openapi: { title: 'Users API', description: 'users service' },
debug: true,
});
// Serves /api/v1/users/* (OpenAPI) and /api/rpc/v1/... (typed RPC) on :3001.
The users service listens on port 3001 and exposes two parallel transports from the same
contract: REST-shaped routes under /api/v1/users/* (driven by oc.route({ method, path })
and surfaced in OpenAPI) and a typed RPC channel under /api/rpc/v1/... that the derived client
speaks. Both are the same contract; the difference is only the wire format. The RPC mount
point is /api/rpc/* — not /rpc — and the derived client is configured to target exactly
that base, so you rarely type the path yourself; it follows from the contract and the service
options.
The type pipeline, end to end (a diagram in prose)
Here is the whole journey of a single field — say status on a user — from where it is born to
where it is consumed, with no manual duplication at any hop:
zod schema contract server client / UI
─────────── ─────────── ────────── ───────────────
UsersStatusSchemaV1 ──> oc.route({ POST }) ──> implement(contract) ──> derived RPC client
z.enum([...]) .input(InputV1) .handler(({input}) => await client.users
.output(ResponseV1) ...) // typed in .list(args)
│ │ │ and out │
│ defines the shape │ binds verb + io │ runs your logic │ args + result
▼ ▼ ▼ over typed data ▼ are TYPED from
one definition one boundary one implementation the SAME schema —
no codegen, no drift
Read it left to right. The status field is declared once, in a zod schema. The contract
references that schema in its .input()/.output(). implement() produces a server object
whose handler sees status as a typed field on input and must return it correctly in the
output. The client — derived from the very same contract value — exposes client.users.list(...)
whose argument type and result type are both projected straight from those schemas. A UI island
that calls the client (optionally through @orpc/tanstack-query) inherits those types yet again.
Crucially, no arrow in that diagram is a copy. Each hop is a projection of the previous one — TypeScript reading the contract's types, oRPC reading the contract's routes, zod reading the contract's schemas. Because every consumer reads from the same value rather than from a duplicated declaration, there is no place for the two to disagree. The contract is the only thing anyone authored by hand; everything to its right is inferred.
Change UsersStatusSchemaV1 and the ripple is immediate and compile-time: the handler that
returns the old shape stops compiling, every client call site that reads the removed field stops
compiling, and the OpenAPI document regenerates. The type checker becomes your integration
test. That is the entire payoff of contracts-first: integration bugs that other stacks discover
at runtime, NetScript discovers at build time, because there was never a second copy to fall out
of step.
| Name | Type | Description |
|---|---|---|
input / output shapes |
zod schema |
Declared once. Validated at runtime by implement(), checked at compile time on both server and client. |
route verb + path |
oc.route({ method, path }) |
Declared on the contract. Drives the OpenAPI document and the REST routes; never re-declared in the handler. |
handler argument |
{ input } |
Typed from the contract's .input() schema. The handler body operates on already-validated data. |
handler return |
output type |
Must satisfy the contract's .output() schema or it fails to compile. No hand-written serialization. |
typed client |
derived from contract |
Not generated, not written by hand. Argument and result types are projected from the same contract value. |
OpenAPI document |
emitted by defineService |
Produced from the contract — a published spec that cannot drift from the running server. |
Why this design, and what it costs
The trade-offs, because contracts-first is an opinion, not a free lunch:
- You write the schema first. For a trivial one-off endpoint, declaring a zod schema before writing the handler feels like ceremony. The payoff arrives the moment a second consumer exists (a client, a UI, another service) — which for a real backend is immediately.
- Versioning is explicit, not automatic. A breaking change to a shape is a new contract version
(
versions/v2/), not an in-place edit. This is deliberate friction: it forces you to decide whether callers can migrate, rather than breaking them silently. - The boundary is the contract, not the database. At the early scaffold step the
usershandlers return seeded in-memory records — the contract is proven end to end before a database is wired. Persistence is a later concern that slots in behind an unchanged contract. - zod is the runtime price. Validation runs on every request. That is a deliberate cost: it is also the thing that makes the published OpenAPI document and the compile-time types trustworthy, because the wire is checked against the same shape the types describe.
The oRPC family (@orpc/contract, @orpc/server, @orpc/client, @orpc/zod,
@orpc/tanstack-query) at ^1.14.6 is pinned in the workspace catalog. zod
(jsr:@zod/zod@4.4.3) is pinned per-package in each member's imports section, not in the
catalog. So the contract surface stays consistent across every workspace member.
How contracts-first shows up across the framework
The contract is not just a service idea — it is the unifying idea. The same model that types a service's RPC channel also types the plugin boundaries you compose into a NetScript app:
- Services are the canonical case on this page — a contract, an
implement()-bound router, and adefineService/createServicehost. See the services capability. - Workers and sagas expose their own oRPC services built from contracts, served via the fluent
createService(...).serve()builder, so dispatching a job or driving a saga is a typed client call rather than a hand-rolledfetch. See background jobs and durable sagas. - Triggers are the deliberate exception: they expose raw Hono routes rather than an oRPC contract, because they receive external webhooks whose shapes you do not control. That asymmetry is itself instructive — contracts-first is for boundaries you own; an inbound webhook is a boundary someone else owns. See the plugin model.
Holding those together: the contract is how NetScript makes the internal surfaces of a system type-safe end to end, and the framework is explicit about where that model stops.
Glossary
- Contract — a single typed value declaring a route's method, input shape, and output shape. The boundary between a server and its callers, and the single source of truth for that boundary. See the glossary.
- oRPC — the contract-and-RPC library NetScript builds the boundary on (
@orpc/*). It suppliesoc.route(...),implement(...), and the derived typed client. See the glossary. - zod — the schema library that describes and validates each shape. It is the runtime half of the contract; oRPC is the routing half. See the glossary.
Where to go next
- Do it: the Build a service tutorial walks the contract →
service → typed client → island path with this exact
usersexample. - Hub: the services capability covers
defineServiceversus the fluentcreateService(...).serve()builder and the real ports. - Architecture: the architecture overview places contracts in the larger picture, and the plugin model shows how plugins reuse the same contracts-first seam.
- Reference: the exact exported symbols live in
reference/contracts/andreference/service/.