tRPC Source Code Architecture: The Internals

I set out to understand the internal workings of typescript Remote Procedure Call so here is my research. Welcome to the deep dive into how tRPC v11 actually works under the hood, the builder pattern, proxy magic, middleware resolution, link chains, and the type-level programming that makes end-to-end type safety possible without code generation.


Table of Contents

  1. Repository Structure
  2. Package Layout
  3. The initTRPC Builder
  4. The Procedure Builder
  5. Middleware Resolution Algorithm
  6. The Router: Building and Merging
  7. The Proxy Magic
  8. The Link Chain
  9. The DataLoader and Batching
  10. The Observable Implementation
  11. The RPC Protocol
  12. The Parser System
  13. Data Transformers
  14. HTTP Adapters and Request Dispatch
  15. SSE Stream Producer
  16. Error Handling
  17. Type Inference Pipeline
  18. Complete Request/Response Lifecycle
  19. Key Algorithms
  20. Source Code

1. Repository Structure

Before you can understand how tRPC works, you need to know where the code lives. The project is a monorepo, many packages in one repository, because tRPC ships separately for the server, the client, React bindings, and Next.js integration. This separation is deliberate: a vanilla Node.js server doesn’t need React in its bundle, and a browser client doesn’t need server-side adapters. The most important thing to understand is that the “core” implementation lives in a directory literally named unstable-core-do-not-import; the name itself is a guardrail, discouraging anyone from reaching into internals when a public API exists for the same purpose.

The tRPC monorepo is managed with pnpm workspaces and Turborepo. Understanding the layout is the first step to navigating the codebase.

Hire Runastartup to build, optimize or scale your software!

trpc/
├── packages/
│   ├── server/                    # The heart: runtime, types, adapters
│   ├── client/                    # Framework-agnostic tRPC client
│   ├── react-query/               # Classic React bindings
│   ├── tanstack-react-query/      # New React bindings (recommended)
│   ├── next/                      # Next.js helpers (Pages Router)
│   ├── openapi/                   # OpenAPI generator
│   ├── tests/                     # Cross-package integration tests
│   └── upgrade/                   # Codemod CLI for migrations
├── examples/                      # Official examples
├── www/                           # Documentation site (tRPC.io)
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── lerna.json

Key Insight: The unstable-core-do-not-import Directory

In v11, the core implementation was moved from packages/server/src/core/ to packages/server/src/unstable-core-do-not-import/. The name is intentional,it discourages consumers from importing internal modules directly. All public exports flow through @trpc/server, @trpc/server/http, and @trpc/server/rpc.

graph TD
    subgraph "Public API Surface"
        A["@trpc/server"]
        B["@trpc/server/http"]
        C["@trpc/server/rpc"]
    end

    subgraph "Internal Core"
        D["unstable-core-do-not-import/"]
    end

    subgraph "Adapters"
        E["adapters/fetch/"]
        F["adapters/express"]
        G["adapters/fastify/"]
        H["adapters/standalone"]
    end

    A --> D
    A --> E
    A --> F
    A --> G
    A --> H
    B --> D
    C --> D

    style D fill:#f9f,stroke:#333,stroke-width:2px

2. Package Layout

Every file in tRPC has a specific job, and the directory names tell you what that job is. The server package holds the runtime engine, the HTTP adapters, the observable implementation, and the core that ties them together. The client package holds the proxy, the link chain, and the batching engine. When you understand the layout, you can jump directly to the file that implements any feature without searching. The key insight is that adapters are thin wrappers;they translate framework-specific request objects into a common format, then delegate to the same core dispatch function regardless of whether you’re using Express, Fastify, or Next.js.

packages/server/src/ The Server Package

src/
├── @trpc/server/                  # Public entry point (re-exports)
│   ├── index.ts
│   ├── http.ts
│   └── rpc.ts
├── adapters/                      # HTTP framework adapters
│   ├── fetch/                     # Fetch API (Next.js App Router, Remix, etc.)
│   ├── fastify/
│   ├── aws-lambda/
│   ├── next-app-dir/
│   ├── node-http/
│   ├── express.ts
│   ├── next.ts                    # Next.js Pages Router
│   ├── ws.ts                      # WebSocket adapter
│   ├── standalone.ts              # Vanilla Node.js
│   └── wsEncoder.ts
├── observable/                    # Zero-dependency Observable impl
│   ├── observable.ts
│   ├── operators.ts
│   └── types.ts
├── unstable-core-do-not-import/   # THE ACTUAL CORE
│   ├── initTRPC.ts
│   ├── procedureBuilder.ts
│   ├── procedure.ts
│   ├── middleware.ts
│   ├── router.ts
│   ├── rootConfig.ts
│   ├── transformer.ts
│   ├── parser.ts
│   ├── createProxy.ts             # The ProxyHandler magic
│   ├── error/
│   │   ├── TRPCError.ts
│   │   ├── getErrorShape.ts
│   │   └── formatter.ts
│   ├── http/
│   │   ├── resolveResponse.ts     # Master HTTP dispatch function
│   │   ├── contentType.ts         # Content-type negotiation
│   │   └── getHTTPStatusCode.ts
│   ├── rpc/
│   │   ├── codes.ts               # Error codes
│   │   ├── envelopes.ts           # JSON-RPC 2.0 envelopes
│   │   └── parseTRPCMessage.ts
│   ├── stream/
│   │   ├── sse.ts                 # SSE stream producer
│   │   ├── jsonl.ts               # JSON Lines streaming
│   │   └── tracked.ts             # Tracked envelopes for SSE IDs
│   ├── clientish/                 # Server-side helpers mimicking client types
│   └── vendor/                    # Vendored libs (standard-schema-v1, etc.)
└── vendor/                        # More vendored libraries

packages/client/src/ The Client Package

src/
├── createTRPCClient.ts            # Proxy decoration (the client you use)
├── createTRPCUntypedClient.ts
├── TRPCClientError.ts
├── internals/
│   ├── TRPCUntypedClient.ts       # Runtime layer
│   └── dataLoader.ts              # Batching engine
└── links/
    ├── types.ts                   # OperationLink interface
    ├── internals/
    │   └── createChain.ts         # Link chain execution
    ├── httpLink.ts
    ├── httpBatchLink.ts
    ├── httpBatchStreamLink.ts
    ├── httpSubscriptionLink.ts
    ├── loggerLink.ts
    ├── retryLink.ts
    ├── splitLink.ts
    └── wsLink/
        └── wsLink.ts

3. The initTRPC Builder

Every tRPC server begins with a single call: initTRPC.create(). This is where global configuration; context type, error formatter, data transformer, SSE settings; gets baked into the framework instance that all your routers and procedures derive from. The cleverest part of the implementation is how it carries type information at the compile level while keeping runtime footprint at zero. A field called $types is set to null as any at runtime, but TypeScript treats it as a rich object carrying your context, metadata, and error shape types through every downstream operation. This pattern phantom types is the foundation of tRPC’s entire type-safety story, and understanding it unlocks every other concept in the codebase.

🔗 Source: packages/server/src/unstable-core-do-not-import/initTRPC.ts

The entry point to tRPC is a single instance of TRPCBuilder:

export const initTRPC = new TRPCBuilder();

The Type-Level Builder Pattern

TRPCBuilder<TContext, TMeta> uses generics to accumulate configuration at compile time. Each method returns a new TRPCBuilder with refined type parameters:

flowchart LR
    A["initTRPC<br/>(no context)"] -->|".context<Session>()"| B["TRPCBuilder<Session>"]
    B -->|".meta<AppMeta>()"| C["TRPCBuilder<Session, AppMeta>"]
    C -->|".create(config)"| D["TRPCRootObject<br/>(the 't' instance)"]

    style A fill:#e0f0ff,stroke:#333
    style D fill:#d4edda,stroke:#333,stroke-width:2px
class TRPCBuilder<TContext, TMeta> {
  context<TNewContext>() {
    return new TRPCBuilder<TNewContext, TMeta>();
  }

  meta<TNewMeta>() {
    return new TRPCBuilder<TContext, TNewMeta>();
  }

  create<TOptions>(opts?: TOptions): TRPCRootObject {
    const config: RootConfig = {
      ...opts,
      transformer: getDataTransformer(opts?.transformer ?? defaultTransformer),
      isDev: opts?.isDev ?? globalThis.process?.env?.NODE_ENV !== 'production',
      errorFormatter: opts?.errorFormatter ?? defaultFormatter,
      isServer: opts?.isServer ?? isServerDefault,
      $types: null as any,  // ← PHANTOM TYPE FIELD
    };

    return {
      procedure: createBuilder(),
      middleware: createMiddlewareFactory(),
      router: createRouterFactory(config),
      mergeRouters,
      createCallerFactory,
      _config: config,
    };
  }
}

The Phantom Types Pattern

The $types: null as any line is the most important detail in the entire codebase. At runtime, $types is null. But at compile time, it carries the full type signature of context, meta, error shape, and transformer types. This is how tRPC threads type information through runtime values without affecting the runtime.

graph TB
    subgraph "Compile Time"
        CT["$types: RootTypes<br>{ ctx: Session, meta: AppMeta, errorShape: ..., transformer: ... }"]
    end

    subgraph "Runtime"
        RT["$types: null"]
    end

    CONFIG["config object"] --> CT
    CONFIG --> RT

    style CT fill:#d4edda,stroke:#333
    style RT fill:#f8d7da,stroke:#333

The create() method returns six members:

MemberPurpose
t.procedureCreates a fresh ProcedureBuilder
t.middlewareCreates middleware via factory
t.routerCreates a router from a record of procedures
t.mergeRoutersMerges multiple routers
t.createCallerFactoryCreates server-side callers for testing
t._configThe resolved RootConfig (rarely used directly)

4. The Procedure Builder

A procedure is the atomic unit of a tRPC API, one endpoint that accepts input, runs through middleware, and returns data. The builder pattern used here should feel familiar if you’ve ever used Express middleware chains or jQuery’s fluent API. Each call to .input(), .use(), or .output() returns a new immutable builder with the previous configuration preserved and a new layer added. What makes this special is that the TypeScript generic parameters track the accumulated types, if an input parser changes the shape of the data, every downstream middleware and the final resolver all know the new type automatically. The builder accumulates not just runtime behavior (an array of middleware functions) but also compile-time knowledge (what the input, output, and context look like at each stage).

🔗 Source: packages/server/src/unstable-core-do-not-import/procedureBuilder.ts

The ProcedureBuilder is the most type-heavy file in tRPC, it uses 8 generic type parameters:

interface ProcedureBuilder<
  TContext,              // Server context type
  TMeta,                 // Procedure metadata type
  TContextOverrides,     // Accumulated ctx overrides from middlewares
  TInputIn,              // What callers must provide
  TInputOut,             // What resolvers receive (after parsing)
  TOutputIn,             // What resolvers return
  TOutputOut,            // What callers receive (after output parsing)
  TCaller extends boolean // Whether experimental_caller was set
>

Immutable Builder Pattern

Each builder method calls createNewBuilder(def1, def2) which clones the builder with merged _def:

function createNewBuilder(def1, def2) {
  const { middlewares = [], inputs, meta, ...rest } = def2;
  return createBuilder({
    ...mergeWithoutOverrides(def1, rest),
    inputs: [...def1.inputs, ...(inputs ?? [])],
    middlewares: [...def1.middlewares, ...middlewares],
    meta: def1.meta && meta ? { ...def1.meta, ...meta } : (meta ?? def1.meta),
  });
}

Key mechanic: .input() is implemented as a middleware. When you call .input(zodSchema), the builder:

  1. Calls getParseFn(input) to detect the parser type (Zod, Valibot, Yup, etc.)
  2. Pushes the raw parser into _def.inputs
  3. Pushes createInputMiddleware(parser) into _def.middlewares
flowchart TD
    A["publicProcedure"] -->|".input(z.object({...}))"| B["ProcedureBuilder"]
    B -->|".use(authMiddleware)"| C["ProcedureBuilder"]
    C -->|".output(z.string())"| D["ProcedureBuilder"]
    D -->|".query(resolver)"| E["QueryProcedure"]

    B --> B1["_def.middlewares:<br/>[inputValidator]"]
    C --> C1["_def.middlewares:<br/>[inputValidator, authMiddleware]"]
    D --> D1["_def.middlewares:<br/>[inputValidator, authMiddleware,<br/>outputValidator]"]
    E --> E1["_def.middlewares:<br/>[inputValidator, authMiddleware,<br/>outputValidator, resolveMiddleware]"]

    style E fill:#d4edda,stroke:#333,stroke-width:2px

Input Validation Middleware

async function inputValidatorMiddleware(opts) {
  const rawInput = await opts.getRawInput();
  const parsedInput = await parse(rawInput);   // May throw TRPCError(BAD_REQUEST)

  // Multiple .input() calls merge via object spread
  const combinedInput = isObject(opts.input) && isObject(parsedInput)
    ? { ...opts.input, ...parsedInput }
    : parsedInput;

  return opts.next({ input: combinedInput });
}

Output Validation Middleware

async function outputValidatorMiddleware({ next }) {
  const result = await next();
  if (!result.ok) return result;         // Pass through errors
  const data = await parse(result.data); // Throws INTERNAL_SERVER_ERROR on fail
  return { ...result, data };
}

Terminal Methods: .query(), .mutation(), .subscription()

These methods finalize the builder by wrapping the user’s resolver as the last middleware in the chain:

async function resolveMiddleware(opts) {
  const data = await resolver(opts);
  return {
    marker: middlewareMarker,   // Compile-time guarantee that next() was called
    ok: true,
    data,
    ctx: opts.ctx,
  };
}

5. Middleware Resolution Algorithm

When a request hits a procedure, all those middleware functions you chained together need to run in a specific order. The algorithm is elegant in its simplicity: an array of functions walked by index, where each function receives a next callback that advances to the next index. This is the same concept as Express middleware or Koa’s onion model, you can run code before calling next(), after next() returns, or both. The critical design choice is that errors are caught at each layer and converted to a structured { ok: false, error } result rather than propagating as thrown exceptions. This means a middleware can inspect the downstream result and decide whether to retry, transform the error, or pass it through unchanged.

Source: procedureBuilder.ts:callRecursive

The middleware chain is resolved via index-based recursion through a flat array:

async function callRecursive(index, _def, opts): Promise<MiddlewareResult> {
  try {
    const middleware = _def.middlewares[index]!;
    const result = await middleware({
      ...opts,
      meta: _def.meta,
      input: opts.input,
      next(_nextOpts) {
        return callRecursive(index + 1, _def, {
          ...opts,
          ctx: _nextOpts?.ctx
            ? { ...opts.ctx, ..._nextOpts.ctx }
            : opts.ctx,
          input: 'input' in (_nextOpts ?? {})
            ? _nextOpts.input
            : opts.input,
          getRawInput: _nextOpts?.getRawInput ?? opts.getRawInput,
        });
      },
    });
    return result;
  } catch (cause) {
    return {
      ok: false,
      error: getTRPCErrorFromUnknown(cause),
      marker: middlewareMarker,
    };
  }
}

How It Works

sequenceDiagram
    participant P as procedure()
    participant R as callRecursive(0)
    participant M1 as inputValidator
    participant M2 as authMiddleware
    participant M3 as outputValidator
    participant M4 as resolveMiddleware

    P->>R: opts = { ctx, getRawInput, signal }
    R->>M1: middleware(opts)
    M1->>M1: parse rawInput → input
    M1->>R: next({ input })

    Note over R: index = 1
    R->>M2: middleware(opts + input)
    M2->>M2: check ctx.session
    alt No session
        M2-->>R: throw TRPCError(UNAUTHORIZED)
        R-->>P: { ok: false, error }
    else Has session
        M2->>R: next({ ctx: { session } })
    end

    Note over R: index = 2
    R->>M3: middleware(opts + session)
    M3->>R: next()
    Note over R: index = 3

    R->>M4: resolveMiddleware(opts)
    M4->>M4: const data = await resolver(opts)
    M4-->>M3: { ok: true, data }

    M3->>M3: parse(data) via outputValidator
    M3-->>M2: { ok: true, data: validatedData }
    M2-->>M1: { ok: true, data }
    M1-->>R: { ok: true, data }
    R-->>P: { ok: true, data }

Key properties of this algorithm:

  1. Order preservation: Middlewares execute in the order they were added to _def.middlewares
  2. Context accumulation: Each middleware’s ctx override is merged via spread ({ ...opts.ctx, ...nextOpts.ctx })
  3. Error short-circuiting: If any middleware throws, the error is caught and the chain returns { ok: false }
  4. Type narrowing: The TypeScript types track context overrides so downstream middleware sees non-nullable fields

6. The Router: Building and Merging

A router is just a container for procedures, but the way tRPC stores them internally is clever. When you define router({ users: userRouter, posts: postRouter }), the builder recursively walks the tree and produces two parallel data structures: a nested record that preserves the original shape (so the client proxy can traverse it), and a flat dictionary keyed by dotted paths like 'users.getById' (so the server can look up any procedure in O(1) time at request dispatch). This dual representation means the developer experience of writing router({ users: { list: ... } }) maps cleanly to both the type system and the runtime lookup engine. The router also supports lazy loading via dynamic imports, which is essential for large codebases where you don’t want every procedure loaded at startup.

Source: packages/server/src/unstable-core-do-not-import/router.ts

Core Data Structure

interface RouterDef<TRoot, TRecord> {
  _config: RootConfig<TRoot>;
  router: true;
  procedures: Record<string, AnyProcedure>;  // FLAT: dotted-path → procedure
  record: TRecord;                            // NESTED: original tree shape
  lazy: Record<string, LazyLoader>;           // Keyed by dotted path
}

A router maintains two parallel structures:

  • record the original nested tree (used by the client proxy to understand shape)
  • procedures a flat dictionary keyed by dotted path (used for O(1) lookup at request time)

The step() Function

When you call t.router({ users: userRouter, posts: postRouter }), the step() function recursively walks the input:

flowchart TD
    INPUT["Input:<br/>{ users: userRouter, posts: postRouter }"]

    INPUT --> STEP["step(input, path=[])"]

    STEP --> CHECK{For each key}

    CHECK -->|"isLazy(item)"| LAZY["Register lazy loader<br/>defer until accessed"]
    CHECK -->|"isRouter(item)"| RECURSE["step(item._def.record,<br/>[...path, key])"]
    CHECK -->|"isProcedure(item)"| REGISTER["procedures[path.key] = item<br/>aggregate[key] = item"]
    CHECK -->|"plain object"| NEST["step(item, [...path, key])"]

    RECURSE --> STEP
    NEST --> STEP

    REGISTER --> OUTPUT
    LAZY --> OUTPUT

    OUTPUT["Result:<br/>procedures = { 'users.list': ..., 'posts.create': ... }<br/>record = { users: { list: ..., create: ... } }"]

    style OUTPUT fill:#d4edda,stroke:#333,stroke-width:2px

Lazy Loading

For large routers, procedures can be loaded on demand via dynamic imports:

const appRouter = router({
  // This won't be imported until first accessed
  analytics: lazy(() => import('./routers/analytics')),
});

The lazy loader uses once() to memoize the dynamic import happens only on first access:

function createLazyLoader(opts) {
  return {
    ref: opts.ref,
    load: once(async () => {
      const router = await opts.ref();
      opts.aggregate[opts.key] = step(router._def.record, opts.path);
      // Recursively register nested lazy loaders
      for (const [key, item] of Object.entries(router._def.lazy)) {
        lazy[`${opts.path}.${key}`] = createLazyLoader({ ... });
      }
    }),
  };
}

Router Merging

mergeRouters(a, b, c) uses mergeWithoutOverrides which throws on duplicate keys:

const record = mergeWithoutOverrides(
  {},
  ...routerList.map((r) => r._def.record),
);

Reserved Words

The names ['then', 'call', 'apply'] are forbidden as procedure or router names. This is critical because:

  • A Proxy with a then method makes JavaScript treat it as a thenable Promise.resolve(proxy) would recurse forever
  • call and apply are handled specially by the proxy for explicit this binding

7. The Proxy Magic

When you write trpc.user.getById.query('1'), you’re not calling a function you’re traversing a chain of JavaScript Proxy objects that record each property access as a path segment. This is the trick that makes tRPC feel like you’re calling functions directly, when in reality the proxy is intercepting every dot access and building up a path array behind the scenes. When you finally call .query() or .mutate(), the apply trap fires and the accumulated path plus arguments are handed to a callback that performs the actual network request. The implementation has to guard against several edge cases: then must return undefined to prevent infinite Promise coercion, and toString/valueOf must return debug strings so React’s rendering pipeline doesn’t recurse into the proxy. This is arguably the most clever piece of engineering in the entire codebase a few dozen lines of Proxy handler code create the illusion of a type-safe remote function call.

Source: packages/server/src/unstable-core-do-not-import/createProxy.ts

tRPC’s type-safe client experience is powered by JavaScript Proxy objects. The proxy accumulates a path array as you chain property accesses, then fires a callback when you call it as a function.

createRecursiveProxy(callback)

function createInnerProxy(callback, path, memo) {
  const cacheKey = path.join('.');

  memo[cacheKey] ??= new Proxy(noop, {
    // Property access → extend the path
    get(_obj, key) {
      if (typeof key !== 'string' || key === 'then') return undefined;
      return createInnerProxy(callback, [...path, key], memo);
    },

    // Function call → invoke callback with accumulated path
    apply(_1, _2, args) {
      const lastOfPath = path[path.length - 1];

      // Special method guards
      if (lastOfPath === 'valueOf' || lastOfPath === 'toString' || lastOfPath === 'toJSON') {
        return `tRPC.proxy(${path.slice(0, -1).join('.')})`;
      }

      return callback({ args, path });
    },
  });

  return memo[cacheKey];
}

Key implementation details:

  1. memo cache: Each unique path gets a single Proxy instance, so proxy.a.b.c always returns the same object
  2. then guard: Returns undefined to prevent Promise-coercion recursion
  3. valueOf/toString/toJSON guards: Return debug strings to prevent infinite recursion during React rendering or console logging

How the Client Proxy Works

flowchart LR
    A["client.user.getById<br/>.query('1')"]

    subgraph "Recursive Proxy"
        direction TB
        B["path = []"]
        B --> C[".user → path = ['user']"]
        C --> D[".getById → path = ['user', 'getById']"]
        D --> E[".query('1') → apply trap fires"]
    end

    E --> F["callback({ path: ['user','getById','query'],<br/>args: ['1'] })"]

    F --> G["Pop 'query' as procedureType<br/>fullPath = 'user.getById'"]
    G --> H["client.query('user.getById', '1')"]

    style A fill:#e0f0ff,stroke:#333
    style H fill:#d4edda,stroke:#333,stroke-width:2px

// packages/client/src/createTRPCClient.ts
export function createTRPCClientProxy<TRouter>(client: TRPCUntypedClient<TRouter>) {
  const proxy = createRecursiveProxy(({ path, args }) => {
    const pathCopy = [...path];
    const callType = pathCopy.pop()!;  // 'query', 'mutate', or 'subscribe'
    const fullPath = pathCopy.join('.');
    const procedureType = clientCallTypeMap[callType];

    return client[procedureType](fullPath, ...args);
  });

  return createFlatProxy((key) => {
    if (key === untypedClientSymbol) return client;
    return proxy[key];
  });
}

How the Server Caller Proxy Works

The server-side caller (createCallerFactory) uses the same recursive proxy but invokes procedures directly:

return function createCaller(ctxOrCallback) {
  return createRecursiveProxy(async ({ path, args }) => {
    const fullPath = path.join('.');
    const procedure = await getProcedureAtPath(router, fullPath);

    if (!procedure) {
      throw new TRPCError({ code: 'NOT_FOUND', message: `No procedure on path "${fullPath}"` });
    }

    const ctx = isFunction(ctxOrCallback)
      ? await Promise.resolve(ctxOrCallback())
      : ctxOrCallback;

    return procedure({
      path: fullPath,
      getRawInput: async () => args[0],
      ctx,
      type: procedure._def.type,
      signal: opts?.signal,
      batchIndex: 0,
    });
  });
};

So caller.user.getById('1') produces path = ['user', 'getById'], joins to 'user.getById', looks up the procedure, and invokes it through the middleware chain.


Links are the client-side equivalent of server middleware, a pipeline of functions that each get a chance to inspect, modify, or redirect an operation before it reaches the network. The chain is built once at client creation time, and each link is called in order for every operation. Non-terminating links (like loggerLink or retryLink) wrap the next() call to add behavior around the request, while terminating links (like httpBatchLink or httpSubscriptionLink) actually perform the HTTP request and produce a response. This architecture lets you compose cross-cutting concerns, logging, retrying, splitting subscriptions from queries, without modifying any individual link’s code. The recursive execution function is surprisingly short, but it enables a powerful composition model that would be hard to achieve with plain function calls.

Source: packages/client/src/links/internals/createChain.ts

Links form a chain of responsibility on the client side. Each link is a function that receives an operation and a next function to pass it downstream.

Link Interface

type OperationLink<TRouter> = (opts: {
  op: Operation;  // { id, type, input, path, context, signal }
  next: (op: Operation) => Observable<OperationResultEnvelope>;
}) => Observable<OperationResultEnvelope>;

Chain Execution

function createChain(opts) {
  return observable((observer) => {
    function execute(index = 0, op = opts.op) {
      const link = opts.links[index];
      if (!link) throw new Error('No more links to execute');

      return link({
        op,
        next(nextOp) {
          return execute(index + 1, nextOp);
        },
      });
    }

    return execute().subscribe(observer);
  });
}

flowchart LR
    OP["Operation<br/>{ type: 'query', path: 'user.getById', input: '1' }"]

    OP --> L1["loggerLink<br/>(non-terminating)"]
    L1 -->|"next(op)"| L2["splitLink<br/>(non-terminating)"]

    L2 -->|"condition: true"| L3a["httpSubscriptionLink<br/>(terminating)"]
    L2 -->|"condition: false"| L3b["httpBatchLink<br/>(terminating)"]

    L3a --> SERVER["HTTP Server (SSE)"]
    L3b --> SERVER2["HTTP Server (JSON)"]

    style L3a fill:#ffe6cc,stroke:#333
    style L3b fill:#d4edda,stroke:#333
    style L1 fill:#e0f0ff,stroke:#333
    style L2 fill:#e0f0ff,stroke:#333

Terminating vs. Non-Terminating Links

LinkTypeBehavior
httpBatchLinkTerminatingActually sends HTTP request
httpLinkTerminatingSends single HTTP request
httpSubscriptionLinkTerminatingOpens SSE connection
httpBatchStreamLinkTerminatingStreams batched responses
wsLinkTerminatingOpens WebSocket connection
loggerLinkNon-terminatingLogs and calls next()
retryLinkNon-terminatingRetries on failure
splitLinkNon-terminatingRoutes to different links

Terminating links don’t call next() they perform the actual network request. Non-terminating links wrap next() to intercept and transform operations.


9. The DataLoader and Batching

If your React component tree fires five separate queries on mount, you don’t want five HTTP requests, you want one. The DataLoader makes this happen by collecting all operations queued in the same JavaScript tick and flushing them as a single batched request on the next macrotask. The mechanism exploits a fundamental property of the JavaScript event loop: synchronous code always runs to completion before any timer callback fires, so any number of .query() calls in the same render cycle are guaranteed to be in the queue before the flush happens. On the server side, each call in the batch runs independently in parallel, and the results are assembled into an array that maps back to each caller’s Promise. This is one of tRPC’s most impactful performance features, it eliminates the N+1 request problem that plagues component-based UIs without requiring any developer awareness.

Source: packages/client/src/internals/dataLoader.ts

The batching magic is powered by a lightweight DataLoader implementation. Multiple procedure calls in the same JavaScript tick are collected and sent as a single HTTP request.

How It Works

sequenceDiagram
    participant C1 as Component A
    participant C2 as Component B
    participant C3 as Component C
    participant DL as dataLoader
    participant Timer as setTimeout
    participant HTTP as HTTP Server

    par Same JavaScript tick
        C1->>DL: load(query1)
        Note over DL: pendingItems = [query1]
        DL->>Timer: schedule dispatch()

        C2->>DL: load(query2)
        Note over DL: pendingItems = [query1, query2]

        C3->>DL: load(query3)
        Note over DL: pendingItems = [query1, query2, query3]
    end

    Timer->>DL: dispatch()
    Note over DL: groupItems → 1 batch
    DL->>HTTP: POST /api/trpc?batch=1<br/>Body: {0: query1, 1: query2, 2: query3}

    HTTP-->>DL: [result1, result2, result3]

    DL->>C1: resolve(result1)
    DL->>C2: resolve(result2)
    DL->>C3: resolve(result3)

function dataLoader<TKey, TValue>(batchLoader) {
  let pendingItems = null;
  let dispatchTimer = null;

  function load(key) {
    const item = {
      aborted: false,
      key,
      batch: null,
      resolve: throwFatalError,
      reject: throwFatalError,
    };

    const promise = new Promise((resolve, reject) => {
      item.reject = reject;
      item.resolve = resolve;
      pendingItems ??= [];
      pendingItems.push(item);
    });

    // Schedule flush on next macrotask
    dispatchTimer ??= setTimeout(dispatch);

    return promise;
  }

  function dispatch() {
    // Clear timer
    clearTimeout(dispatchTimer);
    dispatchTimer = null;

    // Snapshot and clear pending items
    const items = pendingItems;
    pendingItems = null;

    // Group into batches (respecting maxURLLength, maxItems)
    const grouped = groupItems(items, batchLoader.validate);

    // Execute each batch
    for (const batch of grouped) {
      batchLoader.fetch(batch.keys).then(
        (results) => batch.items.forEach((item, i) => item.resolve(results[i])),
        (error) => batch.items.forEach((item) => item.reject(error)),
      );
    }
  }

  return { load };
}

Why this works: JavaScript’s event loop guarantees that synchronous code runs to completion before any setTimeout callback fires. So all .query() calls in the same synchronous block are guaranteed to be collected before dispatch() runs.


10. The Observable Implementation

tRPC needs a way to represent asynchronous data streams for subscriptions that push multiple values over time, and for link chains that might retry or multicast operations. Most libraries solve this by pulling in RxJS, which is powerful but adds significant bundle weight. tRPC instead ships its own tiny Observable implementation: less than 100 lines of code, zero dependencies, and just enough API surface to support the subscribe, next, error, complete, and pipe patterns the rest of the codebase needs. The implementation guarantees that after an error or complete event, no further values are emitted a property that prevents subtle bugs in cleanup logic. Bridge functions convert between Observables and Promises, ReadableStreams, and AsyncIterables, which is how the modern async-generator subscription API coexists with the Observable-based link chain.

Source: packages/server/src/observable/observable.ts

tRPC ships a zero-dependency Observable implementation rather than pulling in RxJS. This keeps the client bundle tiny (~12kb gzipped).

export function observable<TValue, TError>(subscribe) {
  const self: Observable<TValue, TError> = {
    subscribe(observer) {
      let teardownRef = null;
      let isDone = false;
      let unsubscribed = false;
      let teardownImmediately = false;

      function unsubscribe() {
        teardownRef?.();
        teardownRef = null;
      }

      teardownRef = subscribe({
        next(value) {
          if (isDone) return;
          observer.next?.(value);
        },
        error(err) {
          if (isDone) return;
          isDone = true;
          observer.error?.(err);
          unsubscribe();
        },
        complete() {
          if (isDone) return;
          isDone = true;
          observer.complete?.();
          unsubscribe();
        },
      });

      if (teardownImmediately) unsubscribe();

      return { unsubscribe };
    },

    pipe(...operations) {
      return operations.reduce(pipeReducer, self);
    },
  };

  return self;
}

Adapters

The file also provides bridge functions:

AdapterPurpose
observableToPromiseResolves on first next, rejects on error
observableToReadableStreamBridges to web ReadableStream<Result<T>>
observableToAsyncIterableWraps a ReadableStream reader in an async iterator

These bridges unify the Observable-based subscription protocol with the modern AsyncIterable-based API.


11. The RPC Protocol

tRPC doesn’t invent a wire format from scratch, it builds on JSON-RPC 2.0, a well-understood standard with predictable request and response shapes. Every tRPC call is a JSON-RPC message with a method (query, mutation, or subscription), a params object containing the procedure path and input, and a unique ID for matching batched responses. The error codes borrow JSON-RPC’s reserved negative range (-32000 to -32099) and map the last three digits to HTTP status codes, so a NOT_FOUND error carries code -32004 and maps to HTTP 404. Understanding the wire protocol matters when you need to debug with raw network tools, integrate with non-JavaScript clients, or write a custom adapter for a platform tRPC doesn’t officially support yet.

Source: packages/server/src/unstable-core-do-not-import/rpc/

tRPC uses a JSON-RPC 2.0 variant as its wire protocol.

Error Codes

The error codes borrow JSON-RPC 2.0’s reserved -32000 to -32099 range:

PARSE_ERROR:            -32700,
BAD_REQUEST:            -32600,
INTERNAL_SERVER_ERROR:  -32603,
UNAUTHORIZED:           -32001,
FORBIDDEN:              -32003,
NOT_FOUND:              -32004,
TIMEOUT:                -32008,
CONFLICT:               -32009,
CLIENT_CLOSED_REQUEST:  -32099,

Wire Format

A tRPC request looks like:

{
  "id": 1,
  "jsonrpc": "2.0",
  "method": "query",
  "params": {
    "path": "user.getById",
    "input": { "id": "123" }
  }
}

A successful response:

{
  "id": 1,
  "jsonrpc": "2.0",
  "result": {
    "data": { "id": "123", "name": "Alice" }
  }
}

For batched requests, the body is an array of request objects.

SSE Subscription Messages

Subscriptions add special message types:

// Server → Client: subscription started
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "started" } }

// Server → Client: data event
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "data", "data": {...} } }

// Server → Client: subscription ended
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "stopped" } }

// Server → Client: reconnect needed (e.g., during redeployment)
{ "jsonrpc": "2.0", "method": "reconnect" }

Message Parsing

parseTRPCMessage(obj, transformer) on the server uses assertion functions:

function parseTRPCMessage(obj, transformer) {
  assertIsObject(obj);
  const { id, jsonrpc, method, params } = obj;

  assertIsRequestId(id);
  assertIsJSONRPC2OrUndefined(jsonrpc);

  if (method === 'subscription.stop') return { id, jsonrpc, method };

  assertIsProcedureType(method);
  assertIsObject(params);

  const { input: rawInput, path, lastEventId } = params;
  assertIsString(path);

  const input = transformer.input.deserialize(rawInput);

  return { id, jsonrpc, method, params: { input, path, lastEventId } };
}

12. The Parser System

Input validation is not optional in a real API, you can never trust what the client sends. tRPC could have locked you into one validation library, but instead it supports Zod, Valibot, Yup, Superstruct, ArkType, Scale, and any library implementing the standard-schema spec. It achieves this through duck-typing: a getParseFn() function inspects the shape of the validator object at runtime and picks the right extraction method, .parse() for Zod, .validateSync() for Yup, .assert() for Scale, and so on. This means your choice of validation library is a local concern that doesn’t leak into the framework. The type-level inference system mirrors this flexibility, extracting { in, out } type pairs from each library’s structural interface so your resolver receives the correctly typed parsed input.

Source: packages/server/src/unstable-core-do-not-import/parser.ts

tRPC supports a huge matrix of validation libraries through duck-typing. Each library has a structural interface (“esque”):

InterfaceLibraries
ParserZodEsqueZod (._input, ._output)
ParserValibotEsqueValibot (.schema._types)
ParserArkTypeEsqueArkType (.inferIn, .infer)
ParserStandardSchemaEsqueAny standard-schema-compliant lib
ParserMyZodEsquemyzod (.parse)
ParserSuperstructEsqueSuperstruct (.create)
ParserYupEsqueYup (.validateSync)
ParserScaleEsqueScale (.assert)
ParserCustomValidatorEsqueCustom function (input) => T

getParseFn() Runtime Duck-Typing

The parser detection runs in order:

function getParseFn(procedureParser) {
  // 1. ArkType (functions that return unions)
  if (typeof parser === 'function' && typeof parser.assert === 'function') return parser.assert;

  // 2. Valibot v0.31+ or custom function
  if (typeof parser === 'function' && !isStandardSchema) return parser;

  // 3. Zod async
  if ('parseAsync' in parser) return parser.parseAsync.bind(parser);

  // 4. Zod sync / Valibot <v0.13
  if ('parse' in parser) return parser.parse.bind(parser);

  // 5. Yup
  if ('validateSync' in parser) return parser.validateSync.bind(parser);

  // 6. Superstruct
  if ('create' in parser) return parser.create.bind(parser);

  // 7. Scale
  if ('assert' in parser) return parser.assert.bind(parser);

  // 8. Standard Schema spec
  if ('~standard' in parser) return (value) => parser['~standard'].validate(value);

  throw new Error('Could not find a validator fn');
}

The standard-schema branch is notable, tRPC vendors the standard-schema spec to support any compliant validation library.


13. Data Transformers

JSON is the universal data format for web APIs, but it has a well-known limitation: it flattens Date objects into strings, loses Map and Set semantics, and can’t distinguish undefined from null. In a type-safe system like tRPC, this creates a painful mismatch, your server says createdAt: Date but your client receives createdAt: string. Data transformers solve this by wrapping the serialization and deserialization steps with a codec that preserves rich type information across the wire. The transformer is split into two halves: one for the client-to-server direction (input) and one for the server-to-client direction (output), each with its own serialize and deserialize functions. In v11, the transformer is configured on the client link rather than the framework instance, which gives you more flexibility, different clients can use different transformers if needed.

Source: packages/server/src/unstable-core-do-not-import/transformer.ts

A CombinedDataTransformer has two halves, one for each direction of data flow:

interface CombinedDataTransformer {
  input:  { serialize, deserialize };  // client → server
  output: { serialize, deserialize };  // server → client
}

flowchart LR
    subgraph Client
        CS["input.serialize()"]
    end

    WIRE["Wire (HTTP)"]

    subgraph Server
        SD["input.deserialize()"]
        SR["output.serialize()"]
    end

    subgraph Client2
        CD["output.deserialize()"]
    end

    CS -->|"client → server"| WIRE
    WIRE --> SD
    SR -->|"server → client"| WIRE
    WIRE --> CD

The default transformer is identity:

const defaultTransformer = {
  input:  { serialize: (o) => o, deserialize: (o) => o },
  output: { serialize: (o) => o, deserialize: (o) => o },
};

For superjson:

const t = initTRPC.create({
  transformer: superjson,  // Both halves configured
});

In v11, the transformer is also set on the client link (not just the server):

// Client
httpBatchLink({
  url: '/api/trpc',
  transformer: superjson,  // v11: moved here from createTRPCNext
});

14. HTTP Adapters and Request Dispatch

Every HTTP request that reaches a tRPC server, whether through Express, Fastify, Next.js, or the Fetch API, eventually arrives at the same function: resolveResponse(). This is the master dispatcher that parses the incoming request, creates the context, looks up the procedure, runs the middleware chain, formats the response, and handles errors. The adapters themselves are deliberately thin: they translate framework-specific request and response objects into a common shape and delegate everything else. This design means tRPC can support new frameworks with minimal code, a new adapter is typically under 80 lines. The dispatch function also handles content negotiation, supporting plain JSON, multipart/form-data for file uploads, and application/octet-stream for binary streams, all through pluggable content-type handlers.

Source: packages/server/src/unstable-core-do-not-import/http/resolveResponse.ts

resolveResponse(opts) is the master function that every HTTP adapter ultimately calls. Whether you use Fetch, Express, Fastify, or Next.js, the dispatch logic is the same.

Adapter → resolveResponse Flow

flowchart TD
    subgraph "Framework Adapters (Thin Wrappers)"
        FETCH["fetchRequestHandler<br/>(Next.js App Router, Remix)"]
        EXPRESS["createExpressMiddleware"]
        FASTIFY["fastifyTRPCPlugin"]
        STANDALONE["createHTTPServer"]
    end

    FETCH --> RR["resolveResponse()"]
    EXPRESS --> RR
    FASTIFY --> RR
    STANDALONE --> RR

    RR --> GRI["getRequestInfo()"]
    GRI --> CTH["ContentType Handler<br/>(JSON / FormData / OctetStream)"]
    CTH --> CC["createContext()"]
    CC --> DISPATCH["Dispatch to procedures"]

    DISPATCH --> SINGLE["Single call →<br/>JSON response"]
    DISPATCH --> BATCH["Batch call →<br/>JSON array response"]
    DISPATCH --> STREAM["Batch + JSONL →<br/>Streamed response"]
    DISPATCH --> SSE["Subscription →<br/>SSE stream"]

    style RR fill:#ffe6cc,stroke:#333,stroke-width:2px

Request Dispatch Steps

  1. HEAD shortcut: Returns 204 No Content (used for warmup)
  2. Parse request: getRequestInfo() determines batching, procedure type, input
  3. Create context: Calls user’s createContext({ req, info })
  4. Validate HTTP method: Query → GET, Mutation → POST, Subscription → GET
  5. Execute procedures: Each call runs through the middleware chain
  6. Format response: Depends on type (JSON, batch array, SSE stream, JSONL stream)

Content-Type Handlers

HandlerMatchUse Case
jsonContentTypeHandlerapplication/jsonStandard requests
formDataContentTypeHandlermultipart/form-dataFile uploads
octetStreamContentTypeHandlerapplication/octet-streamRaw binary

For GET requests with no content-type, falls back to JSON handler (so URLs work in browsers).

HTTP Method Mapping

const TYPE_ACCEPTED_METHOD_MAP = {
  mutation:     ['POST'],
  query:        ['GET'],
  subscription: ['GET'],  // SSE uses GET
};

// With method override (opt-in)
const TYPE_ACCEPTED_METHOD_MAP_WITH_OVERRIDE = {
  mutation:     ['POST'],
  query:        ['GET', 'POST'],  // POST can also query
  subscription: ['GET', 'POST'],
};

15. SSE Stream Producer

Subscriptions in tRPC v11 are delivered over Server-Sent Events, a standard browser protocol for one-way server-to-client streaming over plain HTTP. The SSE stream producer takes an AsyncIterable, the values your subscription generator yields, and converts it into a ReadableStream<Uint8Array> formatted as SSE events with proper event:, data:, and id: fields. The implementation handles several production concerns that are easy to get wrong: periodic ping comments to prevent proxy timeouts, a maxDurationMs timer for serverless environments, and tracked envelopes that enable Last-Event-ID resumption when a connection drops and reconnects. Error events are emitted as structured SSE messages rather than killing the stream, so clients can decide whether to retry or surface the error.

Source: packages/server/src/unstable-core-do-not-import/stream/sse.ts

sseStreamProducer() converts an AsyncIterable<TValue> into a ReadableStream<Uint8Array> conforming to the HTML Server-Sent Events spec.

sequenceDiagram
    participant P as Procedure (AsyncGenerator)
    participant S as sseStreamProducer
    participant R as ReadableStream
    participant C as Client (httpSubscriptionLink)

    C->>S: GET /api/trpc/message.onUpdate
    S->>P: Start iterating
    S-->>C: event: connected<br/>data: {"client": {...}}

    loop While generator yields
        P->>S: yield { message: "Hello" }
        S-->>C: id: tracked-id<br/>event: data<br/>data: {"message":"Hello"}
    end

    Note over S: Every 15s (configurable)
    S-->>C: :ping (comment, not data)

    P->>S: generator returns
    S-->>C: event: return<br/>data: null
    Note over C: Subscription ended

SSE Event Types

EventPurpose
connectedFirst message with client options
dataData yielded by the generator
returnGenerator completed normally
pingKeepalive comment (every 15s by default)
serialized-errorError during generation

Tracked Envelopes

If a yielded value is { id, data }, the id becomes the SSE event-id header. This enables Last-Event-ID resumption, if the connection drops, the client reconnects with the last event ID, and the server can resume from that point.


16. Error Handling

Errors in a distributed system are harder to get right than success paths, because they need to be both machine-readable and human-friendly. tRPC’s error system is built around a single TRPCError class that carries a semantic code (NOT_FOUND, UNAUTHORIZED, CONFLICT, etc.), a human message, and an optional cause chain for debugging. Every error, whether thrown intentionally in your procedure, caused by a validation failure, or resulting from an uncaught exception, is normalized through getTRPCErrorFromUnknown() which wraps anything that isn’t already a TRPCError with an INTERNAL_SERVER_ERROR code. The error is then shaped through the user’s errorFormatter and serialized for the client, where it arrives as a typed object with a .data.code field the client can switch on without string-matching.

Source: packages/server/src/unstable-core-do-not-import/error/

TRPCError Class

class TRPCError extends Error {
  public readonly code: TRPC_ERROR_CODE_KEY;
  public override readonly cause?: Error;

  constructor(opts: { code; message?; cause? }) {
    const cause = getCauseFromUnknown(opts.cause);
    const message = opts.message ?? cause?.message ?? opts.code;
    super(message, { cause });
    this.code = opts.code;
    this.name = 'TRPCError';
  }
}

Error Flow

flowchart TD
    ERROR["Error thrown<br/>(any type)"]

    ERROR --> GTE["getTRPCErrorFromUnknown()"]

    GTE --> CHECK{Already TRPCError?}
    CHECK -->|Yes| RETURN["Return as-is"]
    CHECK -->|No| WRAP["Wrap as TRPCError<br/>code: INTERNAL_SERVER_ERROR<br/>cause: original error"]

    RETURN --> GES["getErrorShape()"]
    WRAP --> GES

    GES --> SHAPE["Error Shape:<br/>{ message, code, data: { code, httpStatus, path, stack? } }"]

    SHAPE --> FORMAT["User errorFormatter()"]
    FORMAT --> SER["Serialized via transformer"]
    SER --> HTTP["HTTP Response<br/>with appropriate status code"]

    style ERROR fill:#f8d7da,stroke:#333
    style HTTP fill:#d4edda,stroke:#333,stroke-width:2px

getCauseFromUnknown()

Handles any thrown value:

function getCauseFromUnknown(cause) {
  if (cause instanceof Error) return cause;
  if (cause === null || cause === undefined || typeof cause === 'function') return undefined;
  if (typeof cause === 'string' || typeof cause === 'number' || typeof cause === 'boolean') {
    return new Error(String(cause));
  }
  // Plain objects
  return new UnknownCauseError(cause);  // Spreads cause fields onto error
}

getTRPCErrorFromUnknown()

function getTRPCErrorFromUnknown(cause) {
  if (cause instanceof TRPCError) return cause;
  if (cause instanceof Error && cause.name === 'TRPCError') return cause as TRPCError; // cross-realm
  const error = new TRPCError({ code: 'INTERNAL_SERVER_ERROR', cause });
  error.stack = cause instanceof Error ? cause.stack : error.stack;
  return error;
}

17. Type Inference Pipeline

The phrase “end-to-end type safety” gets thrown around a lot, but tRPC’s implementation is genuinely impressive when you trace how types flow from server to client. The server’s router type, a deeply nested object of procedure definitions with their input parsers types and output return types, is exported as a type-only import. TypeScript’s compiler walks this tree recursively, mapping each procedure to its client-side representation: query procedures get a .query() method, mutations get .mutate(), and the input/output types are preserved through every transformation layer. The phantom types field ($types: null as any) is the vehicle that carries context types, error shapes, and transformer metadata from the server config all the way to the client’s error handling code. At runtime, none of this type machinery exists, it’s all erased by the compiler, leaving zero overhead.

The end-to-end type safety relies on the phantom types pattern plus recursive type-level programming.

How Types Flow from Server to Client

flowchart TB
    subgraph "Server"
        SR["appRouter = router({...})"]
        AT["type AppRouter = typeof appRouter"]
    end

    subgraph "Type-Only Import (stripped at build)"
        TI["import type { AppRouter }"]
    end

    subgraph "Client"
        CC["createTRPCClient<AppRouter>()"]
        DPR["DecoratedProcedureRecord<br/>walks router type recursively"]
        HOOKS["client.user.getById.query(input)<br/>← fully typed!"]
    end

    SR --> AT
    AT -->|"type-only"| TI
    TI --> CC
    CC --> DPR
    DPR --> HOOKS

    style TI fill:#fff3cd,stroke:#333,stroke-dasharray: 5 5
    style HOOKS fill:#d4edda,stroke:#333,stroke-width:2px

Procedure Type Extraction

interface Procedure<TType, TDef> {
  _def: {
    $types: { input: TDef['input']; output: TDef['output'] };
    procedure: true;
    type: TType;
    inputs: Parser[];
  };
}

Client Decoration

DecoratedProcedureRecord walks the router record recursively, mapping each procedure to its client-side representation:

type DecoratedProcedureRecord<TRoot, TRecord> = {
  [TKey in keyof TRecord]: TRecord[TKey] extends AnyProcedure
    ? DecorateProcedure<
        TRecord[TKey]['_def']['type'],
        {
          input: inferProcedureInput<TRecord[TKey]>;
          output: inferTransformedProcedureOutput<inferClientTypes<TRoot>, TRecord[TKey]>;
          errorShape: inferClientTypes<TRoot>['errorShape'];
          transformer: inferClientTypes<TRoot>['transformer'];
        }
      >
    : TRecord[TKey] extends RouterRecord
      ? DecoratedProcedureRecord<TRoot, TRecord[TKey]>  // ← recurse
      : never;
};

Procedure Input Inference

type inferProcedureInput<TProcedure> =
  undefined extends inferProcedureParams<TProcedure>['$types']['input']
    ? void | inferProcedureParams<TProcedure>['$types']['input']  // optional
    : inferProcedureParams<TProcedure>['$types']['input'];         // required

The undefined extends X check produces void | X when input is optional (callers can omit it) or just X when required.


18. Complete Request/Response Lifecycle

This chapter ties together every concept from the previous seventeen chapters into a single end-to-end trace. Following a single request from the moment a client calls client.user.getById.query('1') through the proxy, the link chain, the DataLoader batching engine, the HTTP transport, the server adapter, the content-type parser, the procedure dispatch, the middleware chain, the resolver, and back, you see how all the pieces fit. The lifecycle diagram is the single most useful reference for debugging tRPC issues: if something goes wrong, you can trace the request through each step and identify exactly where it broke. Whether you’re investigating a batched request that didn’t batch, a mutation that returned the wrong type, or a subscription that won’t reconnect, the answer is somewhere in this flow.

Here is the full lifecycle of client.user.getById.query('1'), tracing through every layer:

sequenceDiagram
    autonumber
    participant Client as Client Component
    participant Proxy as Recursive Proxy
    participant UC as TRPCUntypedClient
    participant Chain as Link Chain
    participant Batch as DataLoader
    participant HTTP as HTTP Transport
    participant Adapter as Fetch Adapter
    participant RR as resolveResponse()
    participant Proc as Procedure
    participant MW as Middleware Chain
    participant Resolver as User Resolver

    Client->>Proxy: client.user.getById.query('1')

    Note over Proxy: path = ['user','getById','query']<br/>apply trap fires, args = ['1']

    Proxy->>UC: client.query('user.getById', '1')

    UC->>UC: Create Operation<br/>{id: 1, type: 'query', path: 'user.getById', input: '1'}

    UC->>Chain: createChain({links, op})

    Chain->>Chain: loggerLink wraps next()
    Chain->>Batch: httpBatchLink → loader.load(op)

    Note over Batch: Queued in pendingItems[]<br/>setTimeout(dispatch) scheduled

    Note over Batch: Next macrotask: dispatch() runs
    Batch->>HTTP: POST /api/trpc/user.getById?batch=1<br/>Body: {"0":{"json":"1"}}

    HTTP->>Adapter: HTTP Request arrives

    Adapter->>RR: resolveResponse({router, req, path, createContext})

    RR->>RR: getRequestInfo()<br/>deserialize input via transformer

    RR->>RR: createContext({req, info})

    RR->>Proc: procedure({path, getRawInput, ctx, signal})

    Proc->>MW: callRecursive(0, _def, opts)

    MW->>MW: [0] inputValidator: parse('1') → input = '1'
    MW->>MW: [1] authMiddleware: check ctx.session ✓
    MW->>MW: [2] outputValidator: next()
    MW->>Resolver: [3] resolveMiddleware: await resolver({ctx, input})

    Resolver-->>MW: return {id: '1', name: 'Alice'}

    MW->>MW: [2] outputValidator: parse(result)
    MW-->>Proc: {ok: true, data: {id:'1', name:'Alice'}}
    Proc-->>RR: data

    RR->>RR: transformTRPCResponse()<br/>serialize via transformer.output
    RR-->>Adapter: Response(JSON, {status: 200})

    Adapter-->>HTTP: HTTP Response

    HTTP-->>Batch: {jsonrpc:'2.0', result:{data:{...}}}

    Batch->>Batch: transformResult()<br/>deserialize via transformer.output
    Batch-->>Chain: Observable.next({result})
    Chain-->>UC: Observable completes
    UC-->>Proxy: Promise resolves
    Proxy-->>Client: {id: '1', name: 'Alice'}

Lifecycle Summary

flowchart TD
    subgraph "Client Side"
        A["client.user.getById.query('1')"]
        B["Recursive Proxy<br/>accumulates path"]
        C["TRPCUntypedClient<br/>creates Operation"]
        D["Link Chain<br/>loggerLink → httpBatchLink"]
        E["DataLoader<br/>collects same-tick calls"]
        F["HTTP Request<br/>POST /api/trpc"]
    end

    subgraph "Server Side"
        G["Framework Adapter<br/>fetchRequestHandler"]
        H["resolveResponse()<br/>parse + createContext"]
        I["getProcedureAtPath()<br/>O(1) lookup"]
        J["Middleware Chain<br/>input → auth → output → resolver"]
        K["Response<br/>serialized via transformer"]
    end

    A --> B --> C --> D --> E --> F
    F --> G --> H --> I --> J --> K
    K -.->|"HTTP Response"| F

    F -.->|"transformResult()"| L["Client receives<br/>typed data"]
    K -.-> L

    style A fill:#e0f0ff,stroke:#333
    style J fill:#ffe6cc,stroke:#333,stroke-width:2px
    style L fill:#d4edda,stroke:#333,stroke-width:2px

19. Key Algorithms Summary

AlgorithmLocationMechanism
Procedure builderprocedureBuilder.tsImmutable clone via createNewBuilder, accumulates _def.middlewares
Middleware resolutionprocedureBuilder.ts:callRecursiveIndex-based recursion with next() closure
Router flatteningrouter.ts:stepRecursive walk producing flat procedures map + nested record
Lazy loadingrouter.ts:createLazyLoaderonce()-memoized dynamic import
Recursive ProxycreateProxy.ts:createInnerProxyPath-array accumulation + memo cache per path
Client proxy decorationcreateTRPCClient.tsPop last path segment as verb, rest as procedure path
Link chainlinks/internals/createChain.ts:executeRecursive link factory invocation returning Observables
Batchinginternals/dataLoader.tsMacrotask-batching with setTimeout(dispatch), groupItems splitter
SSE streamingstream/sse.ts:sseStreamProducerAsyncIterable → ReadableStream with ping events + tracked IDs
RPC parsingrpc/parseTRPCMessage.tsSeries of assertIs* runtime guards
Content negotiationhttp/contentType.tsPluggable handlers per content-type
Error shapingerror/getErrorShape.tsTRPCError → DefaultErrorShape via user errorFormatter
HTTP dispatchhttp/resolveResponse.tsFour branches: single, subscription, JSONL stream, batch
Parser detectionparser.ts:getParseFnOrdered duck-typing across validation libraries

Architecture at a Glance

graph TB
    subgraph "Client"
        CP["Client Proxy<br/>(createRecursiveProxy)"]
        UC["TRPCUntypedClient"]
        LC["Link Chain<br/>(logger → batch)"]
        DL["DataLoader<br/>(batching)"]
    end

    subgraph "Network"
        HTTP["HTTP / SSE"]
    end

    subgraph "Server"
        AD["Framework Adapter<br/>(fetch / express / fastify)"]
        RR["resolveResponse()"]
        CT["Content-Type Handler"]
        CX["createContext()"]
        RT["Router<br/>(flat procedures map)"]
        PR["Procedure<br/>(callRecursive middleware chain)"]
        TR["Transformer<br/>(serialize/deserialize)"]
    end

    CP --> UC --> LC --> DL --> HTTP
    HTTP --> AD --> RR
    RR --> CT --> CX --> RT --> PR
    PR --> TR
    TR -.-> HTTP

20. Source Code

Source Code

ComponentPath
initTRPCpackages/server/src/unstable-core-do-not-import/initTRPC.ts
Procedure Builderpackages/server/src/unstable-core-do-not-import/procedureBuilder.ts
Middlewarepackages/server/src/unstable-core-do-not-import/middleware.ts
Routerpackages/server/src/unstable-core-do-not-import/router.ts
createProxypackages/server/src/unstable-core-do-not-import/createProxy.ts
Parser Systempackages/server/src/unstable-core-do-not-import/parser.ts
Transformerpackages/server/src/unstable-core-do-not-import/transformer.ts
resolveResponsepackages/server/src/unstable-core-do-not-import/http/resolveResponse.ts
Content Typepackages/server/src/unstable-core-do-not-import/http/contentType.ts
RPC Codespackages/server/src/unstable-core-do-not-import/rpc/codes.ts
SSE Streampackages/server/src/unstable-core-do-not-import/stream/sse.ts
TRPCErrorpackages/server/src/unstable-core-do-not-import/error/TRPCError.ts
Observablepackages/server/src/observable/observable.ts
Client Proxypackages/client/src/createTRPCClient.ts
Untyped Clientpackages/client/src/internals/TRPCUntypedClient.ts
Link Chainpackages/client/src/links/internals/createChain.ts
DataLoaderpackages/client/src/internals/dataLoader.ts
Fetch Adapterpackages/server/src/adapters/fetch/fetchRequestHandler.ts

Hire Runastartup to build, optimize or scale your software!

This guide is based on the tRPC main branch. The architecture has evolved significantly from v10, notably the rename of core/ to unstable-core-do-not-import/, the move toward async iterables over observables, the addition of standard-schema support, and SSE replacing WebSocket as the default subscription transport.

Loading