Skip to main content
0.0.x

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>CreateInput or <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.

What flows through the pipeline — and what never needs hand-syncing
NameTypeDescription
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 users handlers 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 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 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 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