Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

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

Was this page helpful?