End-to-end type safety for TypeScript, without giving up REST.
Define a route on the server, call it from the client. Params, query, headers, body
and every error response are typed.
Docs · Try it on StackBlitz · Boilerplate
- Errors are part of the type. Every response the handler can return (
success,post-not-found,invalid-body, ...) is a discriminated union on the client. You handle failures the compiler knows about, notcatch (e: unknown). Return them withnotFound,unauthorized,forbiddenandconflict, and name each one when the client needs to tell them apart. - No lock-in. Give a route a
.path()and it's also a regular REST endpoint, documented by an OpenAPI spec generated from the same definition. Other teams and third parties can use your API without Cuple. - The whole request is typed. URL params, query strings and headers are validated and typed, not just the body.
- Just Express. Cuple routes sit next to your existing ones. Logging, CORS, sessions and middlewares keep working.
- Batteries included. React 19 bindings with Suspense and a cache, OpenAPI 3.0 generation, and MCP tools for AI agents, all from the same route definitions. More are on the way.
npm i @cuple/server @cuple/client express zodServer
const builder = createBuilder(app);
export const routes = {
getPost: builder
.path("/post/:id") // optional, for REST clients
.paramsSchema(z.strictObject({ id: z.coerce.number() }))
.get(async ({ data }) => {
const post = await findPost(data.params.id);
if (!post) return notFound({ result: "post-not-found", message: "No such post" });
return success({ post });
}),
};
initRpc(app, { path: "/rpc", routes });Client
const client = createClient<typeof routes>({ path: "http://localhost:8080/rpc" });
// Like fetch, every response resolves, typed as every result the route can send.
const res = await fetchCuple(client.getPost.get, { params: { id: 1 } });
if (res.result === "post-not-found") console.log(res.message);
// Keep only success; any other result throws.
const { post } = await fetchCuple(client.getPost.get, { params: { id: 1 } }).thenKeepSuccess();React
function Post({ id }: { id: number }) {
const { post } = useGet(client.getPost, { params: { id } }); // suspends until loaded
return <h1>{post.title}</h1>;
}Rename a field on the server and the client stops compiling. Add a new error result and TypeScript shows you every place that should handle it.
| Package | |
|---|---|
@cuple/server |
Express integration: routes, validation, middlewares, SSE |
@cuple/client |
Type-safe client: fetchCuple, fetchCupleSSE |
@cuple/react |
React 19 bindings: Suspense reads, actions, pagination, a cache |
@cuple/openapi |
Generate OpenAPI 3.0 specs from your routes |
@cuple/mcp |
Expose your routes as MCP tools |
@cuple/inspect |
Route metadata at build time (used by openapi and mcp) |
- Documentation
- Boilerplate (React + Express), or open it in StackBlitz
- Examples:
test/src/examples, tests:test/src
Open source:
- PhotoBin: temporary photo albums with end-to-end encryption
- DavidHomeVentory: household inventory with QR-tagged boxes and an Android client
MIT
