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 reads | Instantiations | Check |
|---|---|---|
| the server's source | 4,136,629 | 6.51s |
the server's .d.ts | 42,852 | 0.07s |
| two domains' source | 253,470 | 0.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.