Skip to content

tRPC v12 isn’t on npm yet. The @trpc12/* packages on this site can’t be installed until it’s released. Use v11 today

tRPC

Large routers

Keep type-checking fast with thousands of procedures, by building the server's declarations and typing apps from them.

On this page

A client's types come from the router's type. If the client reads the server's source, TypeScript infers every procedure (input schemas, middleware, what each resolver returns) before it checks a single call. If it reads the server's built declarations (.d.ts), that work is already done and the client pays only for the procedures it calls.

Measured on a generated monorepo with 5000 zod procedures in 50 domain packages, a client app calling 200 of them:

The app readsInstantiationsCheck
the server's source4,136,6296.51s
the server's .d.ts42,8520.07s
two domains' source253,4700.29s

From declarations, the cost doesn't grow with the router. spikes/big-routers generates that monorepo (node big-routers/bench.ts 50 100 from spikes/), and examples/monorepo is a small one laid out the way this page describes, which pnpm example type-checks and runs.

Build the server's declarations

Make each server package a TypeScript project that emits declarations, and have apps reference it:

{
  "compilerOptions": {
    "composite": true,
    "emitDeclarationOnly": true,
    "declarationMap": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "references": [{ "path": "../trpc" }]
}

An app's tsconfig.json lists the api package in references, and imports its router type as usual (import type { AppRouter } from '@acme/api'). tsc -b builds the referenced packages first, then checks the app against their dist/*.d.ts instead of their source, even when the package exports its source files.

In the example, every package extends tsconfig.base.json and lists what it imports, as packages/posts and apps/web do. The root tsconfig.json references them all, so one tsc -b builds and checks the repo.

Split the server by domain (a package per area, each exporting its router and its type, like packages/posts) so tsc -b rebuilds only the packages a change reaches. Keep t and base procedures such as authed in a package of their own that every domain depends on (packages/trpc).

Name the root router's type

Mount the domains in a router whose type you write from theirs:

export type 
type AppRouter = {
    posts: PostsRouter;
    users: UsersRouter;
}
AppRouter
= {
posts: {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
posts
:
type PostsRouter = {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
PostsRouter
;
users: {
    me: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
users
:
type UsersRouter = {
    me: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
UsersRouter
;
}; export const const appRouter: AppRouterappRouter:
type AppRouter = {
    posts: PostsRouter;
    users: UsersRouter;
}
AppRouter
= {
posts: {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
posts
:
const postsRouter: {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
postsRouter
,
users: {
    me: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
users
:
const usersRouter: {
    me: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
usersRouter
,
};

The declarations then refer to each domain's type. Inferred from the object instead, they copy every domain's procedures into the api package's .d.ts, and past a few thousand procedures TypeScript stops with TS7056 ("the inferred type of this node exceeds the maximum length the compiler will serialize"). Write it as a type: an interface isn't accepted as a router. The example's packages/api mounts its three domains this way.

One client or several

With declarations, one client over the whole AppRouter is as cheap as a client of the domains an app uses: the client's types resolve a procedure only when a call reads it, so Pick<AppRouter, 'posts'> or a client per domain gains nothing. The example's apps/web uses one client over the whole API.

Smaller clients help when the app can't read declarations (no build step before type-checking). A client of { posts: PostsRouter } imported from the posts package loads only that domain, about 16× less than the whole router in the measurement.

In the editor

Editors follow project references to the referenced package's source, so the editor re-infers the server even when tsc -b doesn't. To have the editor read declarations too, set disableSourceOfProjectReferenceRedirect: true in the app's tsconfig.json and keep tsc -b --watch running: changes to the server show in the app once rebuilt. With declarationMap: true in the server packages, go-to-definition still lands in their source.

Measuring

tsc -p apps/web --extendedDiagnostics prints the instantiation count and check time of one project; --listFiles shows whether it loaded a package's src/ or its dist/. tsc --generateTrace <dir> records which types took the time.