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 |
| two domains' contracts (source) | 205,566 | 0.22s |
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.
Contracts for apps that need one domain
An app that uses one domain can depend on a contract package instead of the server. It loads no server code (resolvers, database clients, their types), and its calls can be validated against the contract's schemas on the client. Build the contract package's declarations too; its cost is then its schemas'.
In the example, packages/billing-contract is the contract, packages/billing implements it with t.implement(), and apps/billing-widget calls it with validationLink, depending on the contract package alone.
A server that isn't written contract-first can still hand clients a contract: toContract(router) derives one, as examples/openapi does for its openApiLink client. Its types come from the router's, so the app still needs the server's declarations to stay cheap.
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.