Contract-first Quickstart
Declare a shared HTTP interface, implement it, and use it in a typed client.
A separate contract is useful when the HTTP interface should be shared independently of its implementation. Declare it with the core route builder, implement it with a server adapter, and pass the same contract to the client.
This guide builds the same two routes as the Quickstart, using Express and Zod. The methods, paths, inputs, statuses, and client results are equivalent but the output types are declared explicitly rather than inferred from handlers.
Install
pnpm add @rest-rpc/core @rest-rpc/express express zod
Declare the Contract
Put the contract somewhere both server and client can import.
import { route } from "@rest-rpc/core";
import { z } from "zod";
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
});
const create = route
.input(z.object({ title: z.string().min(1) }))
.output(todoSchema);
const get = route
.get("/todos/:id")
.params(z.object({ id: z.string() }))
.query(z.object({ uppercase: z.enum(["yes", "no"]).optional() }))
.response(200, todoSchema)
.response(404, z.object({ code: z.literal("TODO_NOT_FOUND") }));
export const api = { todos: { create, get } };
todos.create uses the default POST /todos/create and returns a plain value.
todos.get declares its HTTP method and path and returns a status envelope.
The schemas also provide runtime validation of handler outputs.
Implement and Register Routes
import express from "express";
import { implement, registerRoutes } from "@rest-rpc/express";
import { api } from "./contract";
type Todo = { id: string; title: string; completed: boolean };
const todos = new Map<string, Todo>();
const create = implement(api.todos.create).handler(({ input: { title } }) => {
const todo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
};
todos.set(todo.id, todo);
return todo;
});
const get = implement(api.todos.get).handler(({ params, query }) => {
const todo = todos.get(params.id);
if (!todo) {
return { status: 404, body: { code: "TODO_NOT_FOUND" } };
}
return {
status: 200,
body: {
...todo,
title: query.uppercase === "yes" ? todo.title.toUpperCase() : todo.title,
},
};
});
const routes = { todos: { create, get } };
const app = express();
app.use(express.json());
registerRoutes(app, routes);
app.listen(3000);
implement() keeps each handler’s input and result tied to its declaration.
The contract itself contains no framework or handler code.
Call the Routes
Import the shared contract directly. No contract generation step is needed.
import { initClient } from "@rest-rpc/core";
import { api } from "./contract";
const client = initClient(api, { baseUrl: "http://localhost:3000" });
const todo = await client.todos.create({ title: "Write docs" });
const response = await client.todos.get({
params: { id: todo.id },
query: { uppercase: "yes" },
});
if (response.status === 200) {
console.log(response.body.title);
} else {
console.log(response.body.code); // "TODO_NOT_FOUND"
}
Next Steps
- Contract Route Builder covers declarations and implementation.
- Schemas and Validation covers runtime validation.
- Express and the other adapter pages cover framework integration.
- Fetch Client and TanStack Query cover client usage.