Contracts & type flow
This page explains why NetScript is contracts-first and how one versioned public type definition
travels from a contract, through a service handler, all the way to a typed client and a UI island —
with no second boundary definition to drift. In a DB-backed product, generated model schemas are the
intentional predecessor to that public definition; in a DB-less product, the public schema is
authored directly. This page 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 definition of the public boundary. 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 no separately generated
client model to reconcile with the server. DB-backed workspaces do intentionally rerun
netscript db generate when their persistence model changes, then narrow or extend that generated
shape into the versioned contract. The contract is not documentation about the public boundary —
it is the public boundary.
Where the public shape begins
There are two valid origins for a contract schema:
- DB-backed: when generated schemas exist, this is the normal path. Run
netscript db generate, import<Model>Schema(and, when needed,<Model>CreateInputor<Model>UpdateInput) from@database/zod, then use.pick(),.omit(), and.extend()to define the versioned public shape. The generator remains authoritative for column types and nullability; the contract decides what may cross the boundary and which public rules are stricter. - DB-less: author the Zod schema in the versioned contract module. This is the right path for in-memory scaffold stages, external APIs, computed shapes, and products without a persistence model.
The database generation step is therefore an optional predecessor, not a competing public API. From the versioned contract onward, both paths are identical: handler, OpenAPI document, SDK query factory, and page all project from the same contract value. The hand-authored Users schema below illustrates the DB-less origin; the following derivation shows the DB-backed path without assuming that a table model is already public API.
DB-backed derivation: select public fields and compose relations
Suppose the generated ProductSchema includes internalCost and deletedAt, while
WarehouseSchema includes internalRegionCode. Those are persistence-only or private fields for
this API, so the contract selects its public fields explicitly. It also constructs the relation
shape explicitly: the generated barrel promises model schemas and CRUD inputs, not a nested
relation-aware public response.
import { z } from 'zod';
import {
ProductCreateInput,
ProductSchema,
ProductUpdateInput,
WarehouseSchema,
} from '@database/zod';
export const ProductApiSchemaV1 = ProductSchema.pick({
id: true,
name: true,
warehouseId: true,
}).extend({
warehouse: WarehouseSchema.pick({ id: true, name: true }),
});
export const ProductCreateApiSchemaV1 = ProductCreateInput.pick({
name: true,
warehouseId: true,
});
export const ProductUpdateApiSchemaV1 = ProductUpdateInput.pick({
name: true,
warehouseId: true,
});
export const ProductResponseSchemaV1 = z.object({
item: ProductApiSchemaV1,
});
internalCost, deletedAt, and internalRegionCode cannot cross this boundary because none is
selected. The relation contains only the warehouse's public id and name, and the versioned API
can evolve that composed shape independently while retaining generated column types and nullability.
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'), // note: your scaffold's port will differ
openapi: { title: 'Users API', description: 'users service' },
debug: true,
});
// Serves /api/v1/users/* (OpenAPI) and /api/rpc/v1/... (typed RPC) on its assigned port.
Aspire injects PORT at runtime, so the entrypoint reads it from the environment; the typed source
of truth is your netscript.config.ts services.<name>.port field, which the scaffold wires as the
fallback default — set the port there rather than editing this line.
The users service listens on its assigned port 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 contract-to-consumer journey of a single field — say status on a user — from its
versioned public schema to where it is consumed, with no manual duplication at any downstream 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 second boundary copy
Read it left to right. The status field is declared once in the versioned public Zod schema. A
DB-backed product may derive that schema from a generated model and add the public-only status rule;
a DB-less product authors it directly. The contract references that result 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 and result types are 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 to the right of the versioned schema 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 public consumer reads from the same value rather than from a duplicated declaration, there is no place for the two to disagree. In the DB-less path the schema is authored directly. In the DB-backed path the database model and generated schema precede it, while the authored contract narrowing owns the public surface. Everything to the contract's 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 |
versioned Zod schema |
Narrowed or extended from generated models when they exist; otherwise authored directly. Validated by implement() and checked on 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 establish the public schema first. In DB-backed products that means selecting and refining generated model fields; in DB-less products it means authoring the Zod shape directly. For a trivial one-off endpoint, doing this before 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. - For DB-less work, the boundary is the contract, not a 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 can remain absent or slot in later behind that public contract. - For DB-backed work, the database is the persistence predecessor, not the public boundary. When generated model schemas exist, contracts normally derive from them so column types and nullability stay aligned. The contract still narrows private fields and owns stricter public validation; exposing the generated model unchanged is a deliberate choice, not the default.
- 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 also serve a typed v1 oRPC contract — for trigger and event introspection plus
management (fire, enable/disable, schedule preview, SSE event subscription) — like workers and
sagas. Their exception is narrower: the webhook ingress endpoint itself
(
POST /api/v1/webhooks/:triggerId) stays a raw, signature-verifying route rather than an oRPC procedure, because it verifies an HMAC over the raw request bytes from external senders 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/.