Contract-first APIs
Zebra's contract-first pattern (oRPC style): define the contract once, then implement it server-side (app.implement) and call it client-side (createClient / createTestClient) — both derive types and runtime validation from the same contract.
@zebra-web/contract— thezccontract builder (Standard Schema V1, zero deps)@zebra-web/core—app.implement(input/output validation)@zebra-web/client— the derived type-safe client (zero deps)
Building a contract
import { zc } from "@zebra-web/contract";
import { z } from "zod";
export const Blog = z.object({ id: z.number(), title: z.string(), content: z.string() });
export const blogContract = {
list: zc
.get("/blogs")
.query(z.object({ page: z.coerce.number().min(1).default(1) }))
.output(z.array(Blog)),
get: zc
.get("/blogs/:id")
.params(z.object({ id: z.coerce.number().int() }))
.output(Blog)
.errors({ blog_not_found: { status: 404 } }),
create: zc
.post("/blogs")
.body(z.object({ title: z.string().min(1), content: z.string() }))
.output(Blog)
.status(201)
.meta({ summary: "Create a blog post", tags: ["blogs"] }),
remove: zc
.delete("/blogs/:id")
.params(z.object({ id: z.coerce.number().int() }))
.status(204),
};Builder methods (chainable, immutable)
| Method | Purpose | Constraint |
|---|---|---|
zc.get/post/put/patch/delete/head/options(path) | create a procedure (method + path) | — |
.params(schema) | path-params schema | — |
.query(schema) | query-params schema | — |
.body(schema) | request-body schema | not allowed on GET/HEAD (rejected at compile time and runtime) |
.output(schema) | response-body schema | — |
.status(n) | response status (default 200) | — |
.errors({ code: { status } }) | declare error codes | documentation / type-level error contract |
.meta(record) | arbitrary metadata (OpenAPI summary, etc.) | — |
.mcp(name, description, options?) | expose as an MCP tool (see MCP Tools) | — |
Schemas are Standard Schema V1-compatible validators (zod 4, valibot, ...). Every call returns a new frozen procedure — safe to share and compose.
Composition: nested routers and prefix()
import { prefix } from "@zebra-web/contract";
const postContract = {
list: zc.get("/"),
get: zc.get("/:id"),
};
const api = {
posts: prefix("/posts", postContract), // /posts, /posts/:id
users: prefix("/users", { list: zc.get("/") }),
};Type inference
import type { InferBody, InferOutput, InferParams, InferQuery } from "@zebra-web/contract";
type CreateBody = InferBody<typeof blogContract.create>; // { title: string; content: string }
type BlogOut = InferOutput<typeof blogContract.get>; // BlogServer-side: app.implement
import { Zebra } from "@zebra-web/core";
import { blogContract } from "./contract";
const app = new Zebra();
app.injectSingleton(BlogService);
app.implement(blogContract, { blog: BlogService }, {
list: async (req, { blog }) => blog.list(req.query.page),
get: async (req, { blog }) => {
const b = await blog.find(req.params.id);
if (b === undefined) throw new HttpError(404, "blog_not_found", "No such blog");
return b;
},
create: async (req, { blog }) => blog.create(await req.body()),
remove: async (req, { blog }) => {
await blog.remove(req.params.id);
}, // status 204 → return undefined
});Signature:
implement(procOrRouter, handlerOrImpls);
implement(procOrRouter, deps, handlerOrImpls, opts?);Runtime validation flow
The handler is wrapped by buildContractHandler, which runs in spec order:
- params validation → failures recorded
- query validation → aggregated with params; all failures throw
ValidationError(422,errorsarray withparams.*/query.*prefixes) - body validation → failure throws 422; on success
req.body()is replaced by the validated value - handler runs
- output validation (
validateOutput: trueby default) → failure throws 500output_validation_failed - serialization:
JSON.stringify(payload)+ the contract-declaredstatus(default 200)
A handler returning a Response is passed through unchanged (skipping output validation and serialization); with status: 204, the handler must return undefined (returning a Response throws invalid_contract_response).
Route-level middleware
A procedure-level impl can be { middlewares, handler }, or middleware can be passed via opts.middlewares:
app.implement(
blogContract,
{ blog: BlogService },
{
create: {
middlewares: [requireAuth(), writeLimit],
handler: async (req, { blog }) => blog.create(await req.body()),
},
},
);Implementation completeness check
implement walks the whole contract tree exhaustively; missing / extra / malformed leaves in the impl throw at registration time (a missing: / extra: / invalid: manifest) — a missed endpoint surfaces at boot, not after deploy.
Client-side: createClient
import { createClient } from "@zebra-web/client";
import { blogContract } from "./contract";
const client = createClient(blogContract, {
baseUrl: "http://localhost:3001",
headers: () => ({ authorization: `Bearer ${token()}` }), // dynamic headers
});
const blogs = await client.list({ query: { page: 1 } }); // Blog[]
const blog = await client.get({ params: { id: 1 } }); // Blog
const created = await client.create({ body: { title: "T", content: "C" } }); // Blog, 201
await client.remove({ params: { id: 1 } }); // undefined (204)Type safety:
- Arguments appear per declaration:
listrequires{ query },getrequires{ params },createrequires{ body }(not required when undeclared). - Return type = the
outputschema'sInferOutput;status: 204→undefined.
Argument shape
interface ClientArgs<Def> = {
params?: ...; // only when the contract declares a params schema
query?: ...; // only when it declares query
body?: ...; // only when it declares body (no GET/HEAD)
headers?: Record<string, string>; // per-call header overrides
signal?: AbortSignal; // cancellation
};createClient(router, opts) options: baseUrl (required), fetch (custom fetch, for test injection), headers (static or function).
Error handling
Non-2xx responses throw ClientError:
import { ClientError } from "@zebra-web/client";
try {
await client.get({ params: { id: 999 } });
} catch (e) {
if (e instanceof ClientError) {
e.status; // 404
e.code; // "blog_not_found" (derived from the Problem+Json type)
e.problem; // full Problem+Json
e.response; // the raw Response
}
}Code derivation: prefer type: "https://errors.zebra.dev/<code>", else map from status (bad_request / unauthorized / forbidden / not_found / validation_failed / http_<status>). Non-JSON error bodies fall back to request_failed.
Testing: createTestClient
@zebra-web/testing's createTestClient connects the same contract to an in-process TestApp, zero sockets:
import { createTestApp, createTestClient } from "@zebra-web/testing";
const app = createTestApp(); // or a composition root like buildForumApp()
// ... register routes/contracts ...
const client = createTestClient(app, blogContract);
const blogs = await client.list({ query: { page: 1 } });Hand-written routes vs contract-first
| Hand-written | Contract-first | |
|---|---|---|
| Validation | manual | params/query/body/output fully automatic |
| Client types | hand-written | derived from the contract |
| Error-code contract | doc convention | typed via errors() |
| Runtime guarantees | none | implement completeness check + input/output validation |