Skip to main content
Alpha

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.

What flows through the pipeline — and what never needs hand-syncing
NameTypeDescription
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 users handlers 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 a defineService/createService host. 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-rolled fetch. 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 supplies oc.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