typesafe-rpc
Overview
This repo is a type-safe RPC library. The server accepts POST bodies { entity, operation, params } and runs the matching handler. The client is a typed proxy: client.entity.operation(params, signal?, context?). Types are shared so client calls are inferred from the schema.
Core types (shared)
- BaseContext:
{ request: Request | Express.Request } - Args<Params, Context>:
{ params: Params; context: Context } - Handler<Params, Context, Result>:
(args: Args<Params, Context>) => Promise<Result> - RpcSchema:
{ [entity: string]: { [operation: string]: Handler<any, any, any> } }
Handlers receive only params and context (no ExtraParams). Extend BaseContext for app-specific context.
Schema and handlers
Schema is a nested object: entity → operation → handler. Use as const so the client gets literal types.
import type { BaseContext, Handler } from 'typesafe-rpc';
type Ctx = BaseContext & { userId?: string };
const getItem: Handler<{ id: string }, Ctx, { name: string }> = async ({ params, context }) => {
return { name: 'Item ' + params.id };
};
export const apiSchema = {
items: {
getById: getItem,
},
} as const;
Server: createRpcHandler
From typesafe-rpc/server:
import { createRpcHandler } from 'typesafe-rpc/server';
const result = await createRpcHandler({
context: { request }, // must include request
operations: apiSchema,
errorHandler: (error) => new Response(JSON.stringify({ error: '...' }), { status: 500 }),
hooks: {
preCall: (args) => {},
postCall: (args, performance) => {},
error: (args, performance, error) => {},
},
});
- Expects
context.request.method === 'POST'; body must be JSON{ entity, operation, params }. - Hook args:
{ entity, operation, params, context }. - Returns the handler result directly; throws
Responseon error. - If no
errorHandler, throws a generic 500 Response.
Client: createRpcClient
From typesafe-rpc/client:
import { createRpcClient } from 'typesafe-rpc/client';
import type { apiSchema } from './api-schema';
// Static headers
const client = createRpcClient<typeof apiSchema>('/api/rpc', { Authorization: 'Bearer ...' });
// Dynamic headers via function (receives context passed to call)
const client = createRpcClient<typeof apiSchema, MyContext>('/api/rpc', (ctx) => ({
Authorization: ctx.token,
}));
const result = await client.items.getById({ id: '1' });
const withAbort = await client.items.getById({ id: '1' }, signal);
const withContext = await client.items.getById({ id: '1' }, undefined, context);
Client signature: client.entity.operation(params, signal?, context?). Calls POST endpoint?entity::operation with body { entity, operation, params }. Use the same schema type (typeof apiSchema) for full inference.
Route and middlewares
From typesafe-rpc/server: Route, Middleware, orMiddleware.
- Middleware<Params, Context>:
(args: Args<Params, Context>) => Promise<void>(throw to abort). - Route: chain
.middleware(...fns)then.handle(handler).- Multiple
.middleware(a, b, c): OR — first success wins (viaorMiddleware). - Chained
.middleware(a).middleware(b): AND — all run in order.
- Multiple
- orMiddleware(...middlewares): runs middlewares in order; returns on first that doesn't throw; if all throw, rethrows the first error.
- OverridableHandler: the handler returned by
.handle()has anoverrideMiddlewares(...middlewares)method to replace middlewares (useful for testing).
import { Route } from 'typesafe-rpc/server';
const handler = new Route<{ id: string }, BaseContext>()
.middleware(authOrAnonymous)
.middleware(requireReadPermission)
.handle(async ({ params, context }) => ({ name: '...' }));
// For testing: bypass middlewares
handler.overrideMiddlewares(mockAuth);
Use the resulting handler as the function stored in the schema (e.g. getById: handler).
Request/response and errors
- Server reads body via
request.json()(Fetch) orrequest.body(Express). - Client sends JSON and parses response with
response.json(). Non-ok responses throwFetchError.
FetchError
From typesafe-rpc/client:
import { FetchError } from 'typesafe-rpc/client';
class FetchError extends Error {
readonly key: string; // error key from response JSON, or 'internalError'
readonly status: number; // HTTP status code
readonly data?: any; // optional data from response JSON
}
The client parses error responses as JSON { key, message, data }. If parsing fails, key defaults to 'internalError' and message is the raw response text.
Conventions in this repo
- Schema and shared types live in
shared/; server inserver/, client inclient/. - Implementations follow the types in
libs/typesafe-rpc/src/shared/rpc-types.ts. Prefer those over README if they differ (e.g. no ExtraParams in Handler). - For full API and examples, see the project README.