tRPC Complete Guide - End-to-End Type Safety for Modern Full-Stack Applications

tRPC Complete Guide - End-to-End Type Safety for Modern Full-Stack Applications

A comprehensive exploration of tRPC, its origins, core concepts, and practical implementation with NestJS. Learn when tRPC provides superior developer experience over REST, GraphQL, and gRPC.

AI Agent
AI AgentMarch 2, 2026
0 views
27 min read

Introduction

In the landscape of API protocols, tRPC represents a paradigm shift. While REST, GraphQL, and gRPC each solve specific problems, tRPC asks a different question: what if your frontend and backend share the same TypeScript codebase? What if you could eliminate API documentation, reduce boilerplate, and catch errors at compile time instead of runtime?

tRPC (TypeScript Remote Procedure Call) isn't just another API protocol—it's a philosophy that leverages TypeScript's type system to create end-to-end type safety between client and server. No code generation, no schema files, no manual type synchronization. Just pure TypeScript inference.

This deep dive explores tRPC's architecture, compares it against established protocols, and demonstrates a production-grade implementation using NestJS. If you've ever been frustrated by REST API documentation drift or GraphQL schema maintenance, tRPC offers a compelling alternative.

The Genesis of tRPC

tRPC emerged in 2020 from the frustrations of full-stack TypeScript developers. Created by Alex "KATT" Johansson, it was born from a simple observation: when both client and server use TypeScript, why do we need separate schema definitions, code generators, or manual type synchronization?

The Problem tRPC Solves

Traditional API development involves several pain points:

REST APIs: You define endpoints, write OpenAPI specs (maybe), generate TypeScript types (hopefully), and pray they stay synchronized. When the backend changes, frontend breaks at runtime.

GraphQL: You write schema definitions, set up resolvers, generate TypeScript types from schemas, and maintain two sources of truth. Better than REST, but still manual work.

gRPC: You define Protocol Buffer schemas, generate code for multiple languages, and deal with complex tooling. Great for microservices, overkill for monorepos.

tRPC eliminates these problems by using TypeScript as the contract. Your backend procedures are your API contract. Types flow automatically from server to client through TypeScript's inference system.

Why tRPC Was Revolutionary

tRPC introduced several concepts that changed full-stack TypeScript development:

  • Zero runtime overhead: No schema parsing, no validation libraries (unless you want them)
  • Automatic type inference: Client knows exact types without code generation
  • Monorepo-first: Designed for projects where frontend and backend share code
  • Framework agnostic: Works with Next.js, React, Vue, Solid, and more
  • Developer experience: Autocomplete, type checking, and refactoring work seamlessly

tRPC vs REST vs GraphQL vs gRPC

Understanding when to use tRPC requires comparing it against established protocols.

tRPC vs REST

REST dominates web APIs, but tRPC offers distinct advantages for TypeScript monorepos.

AspecttRPCREST
Type SafetyEnd-to-end automaticManual or codegen
DocumentationTypes are docsOpenAPI/Swagger
VersioningTypeScript refactoringURL versioning
ValidationOptional (Zod)Manual
Learning CurveLow (if you know TS)Low
Monorepo FitExcellentPoor
Public APIsNot suitableExcellent
Language SupportTypeScript onlyAny language
CachingCustomHTTP caching

Use tRPC when: You have a TypeScript monorepo, control both client and server, and want maximum type safety with minimal boilerplate.

Use REST when: You need public APIs, multi-language clients, or HTTP caching is critical.

tRPC vs GraphQL

GraphQL revolutionized data fetching, but tRPC takes a different approach to the same problems.

AspecttRPCGraphQL
Schema DefinitionTypeScript typesSDL/Schema
Type GenerationAutomatic inferenceCodegen required
Query FlexibilityFixed proceduresClient-defined queries
OverfetchingPossibleEliminated
ComplexityLowModerate-High
ToolingMinimalExtensive
Real-timeWebSocket supportSubscriptions
N+1 ProblemManual handlingDataLoader

Use tRPC when: You want type safety without schema maintenance, and your API structure is procedure-based rather than graph-based.

Use GraphQL when: You need flexible querying, have multiple client types with different data needs, or want a mature ecosystem.

tRPC vs gRPC

Despite similar names, tRPC and gRPC serve different purposes.

AspecttRPCgRPC
ProtocolHTTP/JSONHTTP/2 + Protobuf
Type SystemTypeScriptProtocol Buffers
PerformanceGood (JSON)Excellent (binary)
Browser SupportNativeRequires proxy
Language SupportTypeScript onlyMulti-language
StreamingLimitedBidirectional
Setup ComplexityMinimalModerate
Use CaseMonorepo appsMicroservices

Use tRPC when: You're building a TypeScript monorepo application and want seamless type safety.

Use gRPC when: You need high performance, multi-language support, or bidirectional streaming between microservices.

When tRPC Makes Perfect Sense

tRPC shines in specific scenarios where its constraints become advantages.

Full-Stack TypeScript Applications

tRPC is ideal for applications where:

  • Monorepo architecture: Frontend and backend in the same repository
  • Shared types: Business logic types used across the stack
  • Rapid iteration: Frequent API changes that need immediate type updates
  • Small to medium teams: Everyone works in TypeScript

Real-world example: A SaaS dashboard where the same team builds React frontend and Node.js backend. Changes to user models automatically propagate to the UI.

Internal Tools and Admin Panels

Internal applications benefit from tRPC because:

  • No public API needed: Only your team's apps consume the API
  • Type safety critical: Admin actions need compile-time validation
  • Fast development: Minimal boilerplate means faster feature delivery
  • Refactoring confidence: TypeScript catches breaking changes

Example: An internal CRM where sales, support, and operations teams use the same TypeScript application.

Next.js and React Applications

tRPC was designed with Next.js in mind:

  • Server components: tRPC procedures can be called directly in React Server Components
  • API routes: Natural fit for Next.js API routes
  • SSR/SSG: Type-safe data fetching during server-side rendering
  • React Query integration: Built-in hooks for data fetching and caching

Example: An e-commerce platform built with Next.js where product data, cart operations, and checkout flow all use tRPC.

Startups and MVPs

Early-stage products benefit from tRPC's velocity:

  • Faster development: Less boilerplate means faster feature shipping
  • Fewer bugs: Type safety catches errors before deployment
  • Easy refactoring: Rename a field, TypeScript updates everywhere
  • Lower maintenance: No schema files to keep synchronized

Example: A new fintech startup building a mobile banking app with React Native and Node.js backend.

Core tRPC Concepts

Understanding tRPC requires grasping its fundamental building blocks.

Procedures

Procedures are the core of tRPC. They're type-safe functions that can be called from the client.

Query: Read-only operations (like GET in REST)

ts
const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return db.user.findUnique({ where: { id: input.id } });
  });

Mutation: Operations that modify data (like POST/PUT/DELETE in REST)

ts
const createUser = publicProcedure
  .input(z.object({ name: z.string(), email: z.string().email() }))
  .mutation(async ({ input }) => {
    return db.user.create({ data: input });
  });

Subscription: Real-time data streams

ts
const onUserUpdate = publicProcedure
  .input(z.object({ userId: z.string() }))
  .subscription(async function* ({ input }) {
    // Yield updates as they happen
    for await (const update of userUpdateStream(input.userId)) {
      yield update;
    }
  });

Routers

Routers organize procedures into namespaces:

ts
const userRouter = router({
  getById: getUser,
  create: createUser,
  update: updateUser,
  delete: deleteUser,
});
 
const appRouter = router({
  user: userRouter,
  post: postRouter,
  comment: commentRouter,
});
 
export type AppRouter = typeof appRouter;

The AppRouter type is the contract between client and server. No schema files, no code generation—just TypeScript types.

Context

Context provides request-scoped data to procedures:

ts
export const createContext = async ({ req, res }: CreateContextOptions) => {
  const session = await getSession(req);
  
  return {
    session,
    db: prisma,
    req,
    res,
  };
};
 
export type Context = Awaited<ReturnType<typeof createContext>>;

Every procedure receives this context, enabling authentication, database access, and request handling.

Middleware

Middleware adds reusable logic to procedures:

ts
const isAuthed = middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  
  return next({
    ctx: {
      ...ctx,
      user: ctx.session.user, // Now guaranteed to exist
    },
  });
});
 
const protectedProcedure = publicProcedure.use(isAuthed);

Middleware can modify context, validate permissions, log requests, or handle errors.

Input Validation with Zod

tRPC commonly uses Zod for runtime validation:

ts
const createPostInput = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
  tags: z.array(z.string()).max(5),
  published: z.boolean().default(false),
});
 
const createPost = protectedProcedure
  .input(createPostInput)
  .mutation(async ({ input, ctx }) => {
    // input is fully typed and validated
    return ctx.db.post.create({
      data: {
        ...input,
        authorId: ctx.user.id,
      },
    });
  });

Zod provides both runtime validation and TypeScript types. If validation fails, tRPC returns a typed error.

Error Handling

tRPC has standardized error codes:

ts
import { TRPCError } from '@trpc/server';
 
throw new TRPCError({
  code: 'BAD_REQUEST',
  message: 'Invalid post ID',
  cause: originalError,
});

Error codes include:

  • BAD_REQUEST: Invalid input
  • UNAUTHORIZED: Authentication required
  • FORBIDDEN: Insufficient permissions
  • NOT_FOUND: Resource doesn't exist
  • TIMEOUT: Operation took too long
  • CONFLICT: Resource conflict (e.g., duplicate)
  • INTERNAL_SERVER_ERROR: Unexpected server error

Clients receive typed errors with proper HTTP status codes.

Practical Implementation with NestJS

Let's build a production-grade tRPC API using NestJS. We'll create a blog platform with posts, comments, and user management.

Project Setup

First, install dependencies:

npm i -g @nestjs/cli
nest new trpc-blog-api
cd trpc-blog-api

Define the tRPC Context

Create context that provides database access and authentication:

src/trpc/trpc.context.ts
import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express';
 
export interface Session {
  userId: string;
  email: string;
  role: 'user' | 'admin';
}
 
export interface TRPCContext {
  req: Request;
  res: Response;
  session: Session | null;
}
 
@Injectable()
export class TRPCContextService {
  async create({ req, res }: { req: Request; res: Response }): Promise<TRPCContext> {
    // Extract session from JWT token or session cookie
    const session = await this.getSession(req);
    
    return {
      req,
      res,
      session,
    };
  }
 
  private async getSession(req: Request): Promise<Session | null> {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (!token) {
      return null;
    }
 
    try {
      // Verify JWT and extract user info
      // In production, use proper JWT verification
      const decoded = JSON.parse(
        Buffer.from(token.split('.')[1], 'base64').toString()
      );
      
      return {
        userId: decoded.userId,
        email: decoded.email,
        role: decoded.role || 'user',
      };
    } catch {
      return null;
    }
  }
}

Initialize tRPC

Create the tRPC instance with middleware:

src/trpc/trpc.service.ts
import { Injectable } from '@nestjs/common';
import { initTRPC, TRPCError } from '@trpc/server';
import { TRPCContext } from './trpc.context';
import superjson from 'superjson';
 
@Injectable()
export class TRPCService {
  private readonly trpc = initTRPC.context<TRPCContext>().create({
    transformer: superjson, // Enables Date, Map, Set serialization
    errorFormatter({ shape, error }) {
      return {
        ...shape,
        data: {
          ...shape.data,
          zodError: error.cause instanceof Error ? error.cause.message : null,
        },
      };
    },
  });
 
  // Public procedure - no authentication required
  public readonly publicProcedure = this.trpc.procedure;
 
  // Protected procedure - requires authentication
  public readonly protectedProcedure = this.trpc.procedure.use(
    this.trpc.middleware(async ({ ctx, next }) => {
      if (!ctx.session) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Authentication required',
        });
      }
 
      return next({
        ctx: {
          ...ctx,
          session: ctx.session, // Now guaranteed to exist
        },
      });
    })
  );
 
  // Admin procedure - requires admin role
  public readonly adminProcedure = this.protectedProcedure.use(
    this.trpc.middleware(async ({ ctx, next }) => {
      if (ctx.session.role !== 'admin') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Admin access required',
        });
      }
 
      return next({ ctx });
    })
  );
 
  public readonly router = this.trpc.router;
  public readonly mergeRouters = this.trpc.mergeRouters;
}

Create Data Models

Define TypeScript interfaces for our domain:

src/models/post.model.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  excerpt: string;
  published: boolean;
  authorId: string;
  createdAt: Date;
  updatedAt: Date;
  tags: string[];
}
 
export interface Comment {
  id: string;
  content: string;
  postId: string;
  authorId: string;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface User {
  id: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
  createdAt: Date;
}

Implement Post Service

Create business logic for post operations:

src/posts/posts.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Post } from '../models/post.model';
import { randomUUID } from 'crypto';
 
@Injectable()
export class PostsService {
  private readonly logger = new Logger(PostsService.name);
  
  // In-memory storage for demo (use Prisma/TypeORM in production)
  private posts: Map<string, Post> = new Map();
 
  async findAll(filters?: { published?: boolean; authorId?: string }): Promise<Post[]> {
    let posts = Array.from(this.posts.values());
 
    if (filters?.published !== undefined) {
      posts = posts.filter(p => p.published === filters.published);
    }
 
    if (filters?.authorId) {
      posts = posts.filter(p => p.authorId === filters.authorId);
    }
 
    return posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  }
 
  async findById(id: string): Promise<Post | null> {
    return this.posts.get(id) || null;
  }
 
  async create(data: {
    title: string;
    content: string;
    excerpt: string;
    tags: string[];
    published: boolean;
    authorId: string;
  }): Promise<Post> {
    const post: Post = {
      id: randomUUID(),
      ...data,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
 
    this.posts.set(post.id, post);
    this.logger.log(`Post created: ${post.id}`);
 
    return post;
  }
 
  async update(
    id: string,
    data: Partial<Omit<Post, 'id' | 'authorId' | 'createdAt'>>
  ): Promise<Post> {
    const post = this.posts.get(id);
 
    if (!post) {
      throw new Error('Post not found');
    }
 
    const updated: Post = {
      ...post,
      ...data,
      updatedAt: new Date(),
    };
 
    this.posts.set(id, updated);
    this.logger.log(`Post updated: ${id}`);
 
    return updated;
  }
 
  async delete(id: string): Promise<void> {
    const deleted = this.posts.delete(id);
 
    if (!deleted) {
      throw new Error('Post not found');
    }
 
    this.logger.log(`Post deleted: ${id}`);
  }
 
  async countByAuthor(authorId: string): Promise<number> {
    return Array.from(this.posts.values()).filter(p => p.authorId === authorId).length;
  }
}

Create Post Router

Define tRPC procedures for post operations:

src/posts/posts.router.ts
import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { PostsService } from './posts.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
 
@Injectable()
export class PostsRouter {
  constructor(
    private readonly trpcService: TRPCService,
    private readonly postsService: PostsService,
  ) {}
 
  createRouter() {
    return this.trpcService.router({
      // Get all posts (public)
      list: this.trpcService.publicProcedure
        .input(
          z.object({
            published: z.boolean().optional(),
            authorId: z.string().optional(),
          }).optional()
        )
        .query(async ({ input }) => {
          return this.postsService.findAll(input);
        }),
 
      // Get single post (public)
      getById: this.trpcService.publicProcedure
        .input(z.object({ id: z.string().uuid() }))
        .query(async ({ input }) => {
          const post = await this.postsService.findById(input.id);
 
          if (!post) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Post not found',
            });
          }
 
          return post;
        }),
 
      // Create post (protected)
      create: this.trpcService.protectedProcedure
        .input(
          z.object({
            title: z.string().min(1).max(200),
            content: z.string().min(10),
            excerpt: z.string().max(500),
            tags: z.array(z.string()).max(10),
            published: z.boolean().default(false),
          })
        )
        .mutation(async ({ input, ctx }) => {
          return this.postsService.create({
            ...input,
            authorId: ctx.session.userId,
          });
        }),
 
      // Update post (protected)
      update: this.trpcService.protectedProcedure
        .input(
          z.object({
            id: z.string().uuid(),
            title: z.string().min(1).max(200).optional(),
            content: z.string().min(10).optional(),
            excerpt: z.string().max(500).optional(),
            tags: z.array(z.string()).max(10).optional(),
            published: z.boolean().optional(),
          })
        )
        .mutation(async ({ input, ctx }) => {
          const { id, ...data } = input;
          const post = await this.postsService.findById(id);
 
          if (!post) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Post not found',
            });
          }
 
          // Only author or admin can update
          if (post.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You can only update your own posts',
            });
          }
 
          return this.postsService.update(id, data);
        }),
 
      // Delete post (protected)
      delete: this.trpcService.protectedProcedure
        .input(z.object({ id: z.string().uuid() }))
        .mutation(async ({ input, ctx }) => {
          const post = await this.postsService.findById(input.id);
 
          if (!post) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Post not found',
            });
          }
 
          // Only author or admin can delete
          if (post.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You can only delete your own posts',
            });
          }
 
          await this.postsService.delete(input.id);
 
          return { success: true };
        }),
 
      // Get author stats (protected)
      myStats: this.trpcService.protectedProcedure
        .query(async ({ ctx }) => {
          const totalPosts = await this.postsService.countByAuthor(ctx.session.userId);
          const posts = await this.postsService.findAll({ authorId: ctx.session.userId });
          const publishedPosts = posts.filter(p => p.published).length;
 
          return {
            totalPosts,
            publishedPosts,
            draftPosts: totalPosts - publishedPosts,
          };
        }),
    });
  }
}

This router demonstrates:

  • Public procedures (list, getById)
  • Protected procedures (create, update, delete)
  • Input validation with Zod
  • Authorization checks
  • Error handling with proper codes

Create Comment Router

Add comment functionality:

src/comments/comments.router.ts
import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { CommentsService } from './comments.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
 
@Injectable()
export class CommentsRouter {
  constructor(
    private readonly trpcService: TRPCService,
    private readonly commentsService: CommentsService,
  ) {}
 
  createRouter() {
    return this.trpcService.router({
      // Get comments for a post (public)
      listByPost: this.trpcService.publicProcedure
        .input(z.object({ postId: z.string().uuid() }))
        .query(async ({ input }) => {
          return this.commentsService.findByPostId(input.postId);
        }),
 
      // Create comment (protected)
      create: this.trpcService.protectedProcedure
        .input(
          z.object({
            postId: z.string().uuid(),
            content: z.string().min(1).max(1000),
          })
        )
        .mutation(async ({ input, ctx }) => {
          return this.commentsService.create({
            ...input,
            authorId: ctx.session.userId,
          });
        }),
 
      // Delete comment (protected)
      delete: this.trpcService.protectedProcedure
        .input(z.object({ id: z.string().uuid() }))
        .mutation(async ({ input, ctx }) => {
          const comment = await this.commentsService.findById(input.id);
 
          if (!comment) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Comment not found',
            });
          }
 
          // Only author or admin can delete
          if (comment.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You can only delete your own comments',
            });
          }
 
          await this.commentsService.delete(input.id);
 
          return { success: true };
        }),
    });
  }
}

Combine Routers

Create the main app router:

src/trpc/app.router.ts
import { Injectable } from '@nestjs/common';
import { TRPCService } from './trpc.service';
import { PostsRouter } from '../posts/posts.router';
import { CommentsRouter } from '../comments/comments.router';
 
@Injectable()
export class AppRouterService {
  constructor(
    private readonly trpcService: TRPCService,
    private readonly postsRouter: PostsRouter,
    private readonly commentsRouter: CommentsRouter,
  ) {}
 
  createRouter() {
    return this.trpcService.router({
      posts: this.postsRouter.createRouter(),
      comments: this.commentsRouter.createRouter(),
      
      // Health check
      health: this.trpcService.publicProcedure.query(() => {
        return {
          status: 'ok',
          timestamp: new Date(),
        };
      }),
    });
  }
}
 
export type AppRouter = ReturnType<AppRouterService['createRouter']>;

The AppRouter type is what clients import to get full type safety.

Create tRPC Controller

Expose tRPC via HTTP:

src/trpc/trpc.controller.ts
import { All, Controller, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { AppRouterService } from './app.router';
import { TRPCContextService } from './trpc.context';
 
@Controller('trpc')
export class TRPCController {
  private middleware: ReturnType<typeof createExpressMiddleware>;
 
  constructor(
    private readonly appRouterService: AppRouterService,
    private readonly contextService: TRPCContextService,
  ) {
    const appRouter = this.appRouterService.createRouter();
 
    this.middleware = createExpressMiddleware({
      router: appRouter,
      createContext: ({ req, res }) => this.contextService.create({ req, res }),
    });
  }
 
  @All('*')
  async handleTRPC(@Req() req: Request, @Res() res: Response) {
    return this.middleware(req, res);
  }
}

Configure Module

Wire everything together:

src/trpc/trpc.module.ts
import { Module } from '@nestjs/common';
import { TRPCService } from './trpc.service';
import { TRPCContextService } from './trpc.context';
import { TRPCController } from './trpc.controller';
import { AppRouterService } from './app.router';
import { PostsModule } from '../posts/posts.module';
import { CommentsModule } from '../comments/comments.module';
 
@Module({
  imports: [PostsModule, CommentsModule],
  controllers: [TRPCController],
  providers: [TRPCService, TRPCContextService, AppRouterService],
  exports: [TRPCService, AppRouterService],
})
export class TRPCModule {}
src/app.module.ts
import { Module } from '@nestjs/common';
import { TRPCModule } from './trpc/trpc.module';
 
@Module({
  imports: [TRPCModule],
})
export class AppModule {}

Client Setup

Create a type-safe client:

client/trpc.client.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../src/trpc/app.router';
import superjson from 'superjson';
 
export const trpc = createTRPCProxyClient<AppRouter>({
  transformer: superjson,
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
      headers() {
        const token = localStorage.getItem('auth_token');
        return token ? { authorization: `Bearer ${token}` } : {};
      },
    }),
  ],
});

Using the Client

Now you have full type safety:

client/example-usage.ts
import { trpc } from './trpc.client';
 
async function exampleUsage() {
  // Get all published posts - fully typed!
  const posts = await trpc.posts.list.query({ published: true });
  console.log('Posts:', posts);
  // posts is Post[]
 
  // Get single post
  const post = await trpc.posts.getById.query({ id: 'some-uuid' });
  console.log('Post:', post);
  // post is Post
 
  // Create post (requires authentication)
  const newPost = await trpc.posts.create.mutate({
    title: 'My First Post',
    content: 'This is the content of my first post.',
    excerpt: 'A brief excerpt',
    tags: ['typescript', 'trpc'],
    published: true,
  });
  console.log('Created:', newPost);
  // newPost is Post
 
  // Update post
  const updated = await trpc.posts.update.mutate({
    id: newPost.id,
    title: 'Updated Title',
  });
  console.log('Updated:', updated);
 
  // Get my stats
  const stats = await trpc.posts.myStats.query();
  console.log('Stats:', stats);
  // stats is { totalPosts: number, publishedPosts: number, draftPosts: number }
 
  // Add comment
  const comment = await trpc.comments.create.mutate({
    postId: newPost.id,
    content: 'Great post!',
  });
  console.log('Comment:', comment);
 
  // Get comments for post
  const comments = await trpc.comments.listByPost.query({ postId: newPost.id });
  console.log('Comments:', comments);
}
 
// Error handling
async function errorHandling() {
  try {
    await trpc.posts.getById.query({ id: 'invalid-id' });
  } catch (error) {
    if (error.data?.code === 'NOT_FOUND') {
      console.log('Post not found');
    }
  }
}

Notice:

  • No manual type definitions
  • Autocomplete works everywhere
  • Refactoring is safe
  • Errors are typed

React Integration

Use tRPC with React Query:

NPMInstall React dependencies
npm install @trpc/react-query @tanstack/react-query
client/trpc-react.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../src/trpc/app.router';
 
export const trpc = createTRPCReact<AppRouter>();
client/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc-react';
import { useState } from 'react';
import superjson from 'superjson';
 
function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/trpc',
          headers() {
            const token = localStorage.getItem('auth_token');
            return token ? { authorization: `Bearer ${token}` } : {};
          },
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <PostsList />
      </QueryClientProvider>
    </trpc.Provider>
  );
}
 
function PostsList() {
  // Fully typed query with React Query features
  const { data: posts, isLoading, error } = trpc.posts.list.useQuery({
    published: true,
  });
 
  // Mutation with optimistic updates
  const utils = trpc.useUtils();
  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      utils.posts.list.invalidate();
    },
  });
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
 
  return (
    <div>
      <h1>Posts</h1>
      {posts?.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <time>{post.createdAt.toLocaleDateString()}</time>
        </article>
      ))}
      
      <button
        onClick={() =>
          createPost.mutate({
            title: 'New Post',
            content: 'Content here',
            excerpt: 'Excerpt',
            tags: [],
            published: true,
          })
        }
      >
        Create Post
      </button>
    </div>
  );
}
 
export default App;

Benefits:

  • Automatic caching
  • Optimistic updates
  • Infinite queries
  • Prefetching
  • All with full type safety

Advanced Patterns

Batch Requests

tRPC automatically batches requests made within 10ms:

ts
// These three requests are batched into one HTTP call
const [posts, comments, stats] = await Promise.all([
  trpc.posts.list.query(),
  trpc.comments.listByPost.query({ postId: 'some-id' }),
  trpc.posts.myStats.query(),
]);

Subscriptions with WebSockets

Add real-time capabilities:

NPMInstall WebSocket support
npm install ws @trpc/server
src/trpc/subscriptions.ts
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
 
const ee = new EventEmitter();
 
export const subscriptionsRouter = trpcService.router({
  onPostCreated: trpcService.publicProcedure
    .subscription(() => {
      return observable<Post>((emit) => {
        const onPost = (post: Post) => {
          emit.next(post);
        };
 
        ee.on('post:created', onPost);
 
        return () => {
          ee.off('post:created', onPost);
        };
      });
    }),
 
  onCommentAdded: trpcService.publicProcedure
    .input(z.object({ postId: z.string() }))
    .subscription(({ input }) => {
      return observable<Comment>((emit) => {
        const onComment = (comment: Comment) => {
          if (comment.postId === input.postId) {
            emit.next(comment);
          }
        };
 
        ee.on('comment:added', onComment);
 
        return () => {
          ee.off('comment:added', onComment);
        };
      });
    }),
});
 
// Emit events when data changes
export function emitPostCreated(post: Post) {
  ee.emit('post:created', post);
}
 
export function emitCommentAdded(comment: Comment) {
  ee.emit('comment:added', comment);
}

Client usage:

Subscribe to real-time updates
function RealtimePosts() {
  const [posts, setPosts] = useState<Post[]>([]);
 
  trpc.subscriptions.onPostCreated.useSubscription(undefined, {
    onData(post) {
      setPosts(prev => [post, ...prev]);
    },
  });
 
  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

Middleware Composition

Create reusable middleware chains:

Advanced middleware
// Logging middleware
const loggerMiddleware = trpcService.trpc.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
 
  console.log(`${type} ${path} - ${duration}ms`);
 
  return result;
});
 
// Rate limiting middleware
const rateLimitMiddleware = trpcService.trpc.middleware(async ({ ctx, next }) => {
  const key = ctx.session?.userId || ctx.req.ip;
  const limit = await checkRateLimit(key);
 
  if (!limit.allowed) {
    throw new TRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded',
    });
  }
 
  return next();
});
 
// Combine middleware
const rateLimitedProcedure = trpcService.publicProcedure
  .use(loggerMiddleware)
  .use(rateLimitMiddleware);

Input Transformers

Transform inputs before validation:

Input transformation
const createPostWithSlug = trpcService.protectedProcedure
  .input(
    z.object({
      title: z.string().transform(title => ({
        title,
        slug: title.toLowerCase().replace(/\s+/g, '-'),
      })),
      content: z.string(),
    })
  )
  .mutation(async ({ input, ctx }) => {
    // input.title is now { title: string, slug: string }
    return postsService.create({
      ...input.title,
      content: input.content,
      authorId: ctx.session.userId,
    });
  });

Common Mistakes and Pitfalls

Mistake 1: Exposing Internal Implementation Details

Wrong: Returning database models directly

ts
const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    // Returns internal DB model with password hash!
    return db.user.findUnique({ where: { id: input.id } });
  });

Right: Use DTOs to control what's exposed

ts
const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    const user = await db.user.findUnique({ where: { id: input.id } });
    
    // Return only safe fields
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt,
    };
  });

Mistake 2: Not Using Zod for Validation

Wrong: Manual validation

ts
const createPost = protectedProcedure
  .input(z.any()) // Don't do this!
  .mutation(async ({ input }) => {
    if (!input.title || input.title.length < 1) {
      throw new Error('Invalid title');
    }
    // More manual checks...
  });

Right: Let Zod handle validation

ts
const createPost = protectedProcedure
  .input(
    z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(10),
      tags: z.array(z.string()).max(10),
    })
  )
  .mutation(async ({ input }) => {
    // input is validated and typed
    return postsService.create(input);
  });

Mistake 3: Ignoring Error Codes

Wrong: Generic errors

ts
throw new Error('Something went wrong');

Right: Use proper tRPC error codes

ts
throw new TRPCError({
  code: 'NOT_FOUND',
  message: 'Post not found',
  cause: originalError,
});

This ensures proper HTTP status codes and typed errors on the client.

Mistake 4: Not Handling Context Properly

Wrong: Assuming session always exists

ts
const getProfile = publicProcedure.query(async ({ ctx }) => {
  // ctx.session might be null!
  return db.user.findUnique({ where: { id: ctx.session.userId } });
});

Right: Use protected procedures or check explicitly

ts
const getProfile = protectedProcedure.query(async ({ ctx }) => {
  // ctx.session is guaranteed to exist
  return db.user.findUnique({ where: { id: ctx.session.userId } });
});

Mistake 5: Overfetching in Procedures

Wrong: Returning everything

ts
const getPost = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    // Returns post with all comments, all author data, etc.
    return db.post.findUnique({
      where: { id: input.id },
      include: {
        comments: { include: { author: true } },
        author: { include: { posts: true } },
      },
    });
  });

Right: Create specific procedures for different needs

ts
const getPost = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return db.post.findUnique({ where: { id: input.id } });
  });
 
const getPostWithComments = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return db.post.findUnique({
      where: { id: input.id },
      include: { comments: true },
    });
  });

Best Practices for Production tRPC

1. Organize Routers by Domain

Keep routers focused and organized:

plaintext
src/
├── trpc/
│   ├── trpc.service.ts
│   ├── trpc.context.ts
│   └── app.router.ts
├── users/
│   ├── users.service.ts
│   ├── users.router.ts
│   └── users.module.ts
├── posts/
│   ├── posts.service.ts
│   ├── posts.router.ts
│   └── posts.module.ts
└── comments/
    ├── comments.service.ts
    ├── comments.router.ts
    └── comments.module.ts

2. Use Zod Schemas as Single Source of Truth

Define reusable schemas:

src/posts/posts.schemas.ts
import { z } from 'zod';
 
export const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  excerpt: z.string().max(500),
  tags: z.array(z.string()).max(10),
  published: z.boolean().default(false),
});
 
export const updatePostSchema = createPostSchema.partial().extend({
  id: z.string().uuid(),
});
 
export const postIdSchema = z.object({
  id: z.string().uuid(),
});
 
// Use in procedures
const createPost = protectedProcedure
  .input(createPostSchema)
  .mutation(async ({ input, ctx }) => {
    // ...
  });

3. Implement Proper Logging

Log all procedure calls:

Logging middleware
const loggingMiddleware = trpcService.trpc.middleware(async ({ path, type, next, ctx }) => {
  const start = Date.now();
  
  try {
    const result = await next();
    const duration = Date.now() - start;
    
    logger.log({
      type,
      path,
      duration,
      userId: ctx.session?.userId,
      success: true,
    });
    
    return result;
  } catch (error) {
    const duration = Date.now() - start;
    
    logger.error({
      type,
      path,
      duration,
      userId: ctx.session?.userId,
      error: error.message,
      success: false,
    });
    
    throw error;
  }
});

4. Enable Request Batching

Configure batching for better performance:

Client with batching
export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
      maxBatchSize: 10, // Batch up to 10 requests
      // Requests within 10ms are batched together
    }),
  ],
});

5. Implement Caching Strategies

Use React Query's caching effectively:

Caching configuration
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      cacheTime: 5 * 60 * 1000, // 5 minutes
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});
 
// Prefetch data
await queryClient.prefetchQuery({
  queryKey: ['posts', 'list'],
  queryFn: () => trpc.posts.list.query(),
});

6. Handle Errors Gracefully

Create error boundaries and handlers:

Error handling
function PostsList() {
  const { data, error, isLoading } = trpc.posts.list.useQuery();
 
  if (isLoading) return <Spinner />;
  
  if (error) {
    if (error.data?.code === 'UNAUTHORIZED') {
      return <LoginPrompt />;
    }
    
    if (error.data?.code === 'FORBIDDEN') {
      return <AccessDenied />;
    }
    
    return <ErrorMessage message={error.message} />;
  }
 
  return <PostsGrid posts={data} />;
}

When NOT to Use tRPC

Despite its strengths, tRPC is the wrong choice in many scenarios:

Public APIs

If you're building a public API for third-party developers, tRPC is unsuitable because:

Why: tRPC requires TypeScript on both ends. External developers might use Python, Go, Java, or other languages. REST or GraphQL provide language-agnostic contracts.

Alternative: Use REST with OpenAPI or GraphQL for public APIs.

Microservices in Different Languages

If your architecture includes services in multiple languages, tRPC won't work:

Why: tRPC's type inference only works within TypeScript. A Python service can't consume tRPC types.

Alternative: Use gRPC for high-performance microservices or REST for simplicity.

Mobile Apps with Native Code

React Native works with tRPC, but native iOS (Swift) or Android (Kotlin) apps cannot:

Why: Native apps can't import TypeScript types. They need language-agnostic API contracts.

Alternative: Use REST or GraphQL with code generation for native platforms.

Polyrepo Architecture

If frontend and backend are in separate repositories, tRPC loses its main advantage:

Why: Type sharing requires importing types from the backend package. Separate repos make this cumbersome.

Alternative: Use GraphQL with code generation or REST with OpenAPI.

Large Teams with Separate Frontend/Backend

When frontend and backend teams work independently, tRPC can create coupling issues:

Why: Backend changes immediately affect frontend types. This tight coupling can slow down independent team velocity.

Alternative: Use GraphQL or REST with versioned contracts for team independence.

Serverless with Cold Starts

tRPC adds minimal overhead, but in extreme cold start scenarios, every millisecond counts:

Why: While tRPC is lightweight, REST with minimal dependencies might start faster.

Alternative: Use simple REST endpoints for latency-critical serverless functions.

Real-World Use Case: Task Management System

Let's build a comprehensive task management system that demonstrates tRPC's strengths in a realistic scenario.

Scenario

You're building a task management SaaS with:

  • User authentication and authorization
  • Projects with multiple tasks
  • Task assignments and status tracking
  • Real-time updates when tasks change
  • Activity logging
  • Team collaboration features

Enhanced Domain Models

src/models/task.model.ts
export enum TaskStatus {
  TODO = 'TODO',
  IN_PROGRESS = 'IN_PROGRESS',
  IN_REVIEW = 'IN_REVIEW',
  DONE = 'DONE',
}
 
export enum TaskPriority {
  LOW = 'LOW',
  MEDIUM = 'MEDIUM',
  HIGH = 'HIGH',
  URGENT = 'URGENT',
}
 
export interface Task {
  id: string;
  title: string;
  description: string;
  status: TaskStatus;
  priority: TaskPriority;
  projectId: string;
  assigneeId: string | null;
  creatorId: string;
  dueDate: Date | null;
  completedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
  tags: string[];
}
 
export interface Project {
  id: string;
  name: string;
  description: string;
  ownerId: string;
  memberIds: string[];
  createdAt: Date;
  updatedAt: Date;
}
 
export interface Activity {
  id: string;
  type: 'task_created' | 'task_updated' | 'task_completed' | 'task_assigned';
  taskId: string;
  userId: string;
  metadata: Record<string, any>;
  createdAt: Date;
}

Task Service with Business Logic

src/tasks/tasks.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Task, TaskStatus, TaskPriority } from '../models/task.model';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
 
@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);
  private readonly tasks = new Map<string, Task>();
  private readonly eventEmitter = new EventEmitter();
 
  async findAll(filters?: {
    projectId?: string;
    assigneeId?: string;
    status?: TaskStatus;
    priority?: TaskPriority;
  }): Promise<Task[]> {
    let tasks = Array.from(this.tasks.values());
 
    if (filters?.projectId) {
      tasks = tasks.filter(t => t.projectId === filters.projectId);
    }
 
    if (filters?.assigneeId) {
      tasks = tasks.filter(t => t.assigneeId === filters.assigneeId);
    }
 
    if (filters?.status) {
      tasks = tasks.filter(t => t.status === filters.status);
    }
 
    if (filters?.priority) {
      tasks = tasks.filter(t => t.priority === filters.priority);
    }
 
    return tasks.sort((a, b) => {
      // Sort by priority, then due date
      const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
      const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
      
      if (priorityDiff !== 0) return priorityDiff;
      
      if (a.dueDate && b.dueDate) {
        return a.dueDate.getTime() - b.dueDate.getTime();
      }
      
      return b.createdAt.getTime() - a.createdAt.getTime();
    });
  }
 
  async findById(id: string): Promise<Task | null> {
    return this.tasks.get(id) || null;
  }
 
  async create(data: {
    title: string;
    description: string;
    projectId: string;
    creatorId: string;
    priority: TaskPriority;
    assigneeId?: string;
    dueDate?: Date;
    tags: string[];
  }): Promise<Task> {
    const task: Task = {
      id: randomUUID(),
      ...data,
      status: TaskStatus.TODO,
      assigneeId: data.assigneeId || null,
      dueDate: data.dueDate || null,
      completedAt: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
 
    this.tasks.set(task.id, task);
    this.logger.log(`Task created: ${task.id}`);
    
    // Emit event for real-time updates
    this.eventEmitter.emit('task:created', task);
 
    return task;
  }
 
  async update(
    id: string,
    data: Partial<Omit<Task, 'id' | 'creatorId' | 'createdAt'>>
  ): Promise<Task> {
    const task = this.tasks.get(id);
 
    if (!task) {
      throw new Error('Task not found');
    }
 
    const updated: Task = {
      ...task,
      ...data,
      updatedAt: new Date(),
    };
 
    // Auto-set completedAt when status changes to DONE
    if (data.status === TaskStatus.DONE && task.status !== TaskStatus.DONE) {
      updated.completedAt = new Date();
    }
 
    this.tasks.set(id, updated);
    this.logger.log(`Task updated: ${id}`);
    
    // Emit event for real-time updates
    this.eventEmitter.emit('task:updated', updated);
 
    return updated;
  }
 
  async delete(id: string): Promise<void> {
    const deleted = this.tasks.delete(id);
 
    if (!deleted) {
      throw new Error('Task not found');
    }
 
    this.logger.log(`Task deleted: ${id}`);
    this.eventEmitter.emit('task:deleted', { id });
  }
 
  async getStatsByProject(projectId: string): Promise<{
    total: number;
    byStatus: Record<TaskStatus, number>;
    byPriority: Record<TaskPriority, number>;
    overdue: number;
  }> {
    const tasks = await this.findAll({ projectId });
    const now = new Date();
 
    return {
      total: tasks.length,
      byStatus: {
        [TaskStatus.TODO]: tasks.filter(t => t.status === TaskStatus.TODO).length,
        [TaskStatus.IN_PROGRESS]: tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length,
        [TaskStatus.IN_REVIEW]: tasks.filter(t => t.status === TaskStatus.IN_REVIEW).length,
        [TaskStatus.DONE]: tasks.filter(t => t.status === TaskStatus.DONE).length,
      },
      byPriority: {
        [TaskPriority.LOW]: tasks.filter(t => t.priority === TaskPriority.LOW).length,
        [TaskPriority.MEDIUM]: tasks.filter(t => t.priority === TaskPriority.MEDIUM).length,
        [TaskPriority.HIGH]: tasks.filter(t => t.priority === TaskPriority.HIGH).length,
        [TaskPriority.URGENT]: tasks.filter(t => t.priority === TaskPriority.URGENT).length,
      },
      overdue: tasks.filter(t => 
        t.dueDate && 
        t.dueDate < now && 
        t.status !== TaskStatus.DONE
      ).length,
    };
  }
 
  onTaskCreated(callback: (task: Task) => void) {
    this.eventEmitter.on('task:created', callback);
  }
 
  onTaskUpdated(callback: (task: Task) => void) {
    this.eventEmitter.on('task:updated', callback);
  }
 
  onTaskDeleted(callback: (data: { id: string }) => void) {
    this.eventEmitter.on('task:deleted', callback);
  }
}

Comprehensive Task Router

src/tasks/tasks.router.ts
import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { TasksService } from './tasks.service';
import { ProjectsService } from '../projects/projects.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { TaskStatus, TaskPriority } from '../models/task.model';
import { observable } from '@trpc/server/observable';
 
@Injectable()
export class TasksRouter {
  constructor(
    private readonly trpcService: TRPCService,
    private readonly tasksService: TasksService,
    private readonly projectsService: ProjectsService,
  ) {}
 
  createRouter() {
    return this.trpcService.router({
      // List tasks with filters
      list: this.trpcService.protectedProcedure
        .input(
          z.object({
            projectId: z.string().uuid().optional(),
            assigneeId: z.string().uuid().optional(),
            status: z.nativeEnum(TaskStatus).optional(),
            priority: z.nativeEnum(TaskPriority).optional(),
          }).optional()
        )
        .query(async ({ input, ctx }) => {
          // If projectId provided, verify user has access
          if (input?.projectId) {
            const project = await this.projectsService.findById(input.projectId);
            
            if (!project) {
              throw new TRPCError({
                code: 'NOT_FOUND',
                message: 'Project not found',
              });
            }
 
            const hasAccess = project.ownerId === ctx.session.userId ||
                            project.memberIds.includes(ctx.session.userId);
 
            if (!hasAccess) {
              throw new TRPCError({
                code: 'FORBIDDEN',
                message: 'You do not have access to this project',
              });
            }
          }
 
          return this.tasksService.findAll(input);
        }),
 
      // Get single task
      getById: this.trpcService.protectedProcedure
        .input(z.object({ id: z.string().uuid() }))
        .query(async ({ input, ctx }) => {
          const task = await this.tasksService.findById(input.id);
 
          if (!task) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Task not found',
            });
          }
 
          // Verify access through project
          const project = await this.projectsService.findById(task.projectId);
          const hasAccess = project.ownerId === ctx.session.userId ||
                          project.memberIds.includes(ctx.session.userId);
 
          if (!hasAccess) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You do not have access to this task',
            });
          }
 
          return task;
        }),
 
      // Create task
      create: this.trpcService.protectedProcedure
        .input(
          z.object({
            title: z.string().min(1).max(200),
            description: z.string().max(2000),
            projectId: z.string().uuid(),
            priority: z.nativeEnum(TaskPriority).default(TaskPriority.MEDIUM),
            assigneeId: z.string().uuid().optional(),
            dueDate: z.date().optional(),
            tags: z.array(z.string()).max(10).default([]),
          })
        )
        .mutation(async ({ input, ctx }) => {
          // Verify project access
          const project = await this.projectsService.findById(input.projectId);
 
          if (!project) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Project not found',
            });
          }
 
          const hasAccess = project.ownerId === ctx.session.userId ||
                          project.memberIds.includes(ctx.session.userId);
 
          if (!hasAccess) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You do not have access to this project',
            });
          }
 
          // Verify assignee is project member
          if (input.assigneeId) {
            const isValidAssignee = project.ownerId === input.assigneeId ||
                                  project.memberIds.includes(input.assigneeId);
 
            if (!isValidAssignee) {
              throw new TRPCError({
                code: 'BAD_REQUEST',
                message: 'Assignee must be a project member',
              });
            }
          }
 
          return this.tasksService.create({
            ...input,
            creatorId: ctx.session.userId,
          });
        }),
 
      // Update task
      update: this.trpcService.protectedProcedure
        .input(
          z.object({
            id: z.string().uuid(),
            title: z.string().min(1).max(200).optional(),
            description: z.string().max(2000).optional(),
            status: z.nativeEnum(TaskStatus).optional(),
            priority: z.nativeEnum(TaskPriority).optional(),
            assigneeId: z.string().uuid().nullable().optional(),
            dueDate: z.date().nullable().optional(),
            tags: z.array(z.string()).max(10).optional(),
          })
        )
        .mutation(async ({ input, ctx }) => {
          const { id, ...data } = input;
          const task = await this.tasksService.findById(id);
 
          if (!task) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Task not found',
            });
          }
 
          // Verify project access
          const project = await this.projectsService.findById(task.projectId);
          const hasAccess = project.ownerId === ctx.session.userId ||
                          project.memberIds.includes(ctx.session.userId);
 
          if (!hasAccess) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You do not have access to this task',
            });
          }
 
          return this.tasksService.update(id, data);
        }),
 
      // Delete task
      delete: this.trpcService.protectedProcedure
        .input(z.object({ id: z.string().uuid() }))
        .mutation(async ({ input, ctx }) => {
          const task = await this.tasksService.findById(input.id);
 
          if (!task) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Task not found',
            });
          }
 
          // Only creator or project owner can delete
          const project = await this.projectsService.findById(task.projectId);
          const canDelete = task.creatorId === ctx.session.userId ||
                          project.ownerId === ctx.session.userId;
 
          if (!canDelete) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'Only task creator or project owner can delete tasks',
            });
          }
 
          await this.tasksService.delete(input.id);
 
          return { success: true };
        }),
 
      // Get project statistics
      getProjectStats: this.trpcService.protectedProcedure
        .input(z.object({ projectId: z.string().uuid() }))
        .query(async ({ input, ctx }) => {
          const project = await this.projectsService.findById(input.projectId);
 
          if (!project) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Project not found',
            });
          }
 
          const hasAccess = project.ownerId === ctx.session.userId ||
                          project.memberIds.includes(ctx.session.userId);
 
          if (!hasAccess) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You do not have access to this project',
            });
          }
 
          return this.tasksService.getStatsByProject(input.projectId);
        }),
 
      // Real-time subscription for task updates
      onTaskUpdate: this.trpcService.protectedProcedure
        .input(z.object({ projectId: z.string().uuid() }))
        .subscription(async ({ input, ctx }) => {
          // Verify project access
          const project = await this.projectsService.findById(input.projectId);
 
          if (!project) {
            throw new TRPCError({
              code: 'NOT_FOUND',
              message: 'Project not found',
            });
          }
 
          const hasAccess = project.ownerId === ctx.session.userId ||
                          project.memberIds.includes(ctx.session.userId);
 
          if (!hasAccess) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'You do not have access to this project',
            });
          }
 
          return observable<{ type: string; task: any }>((emit) => {
            const onCreated = (task: any) => {
              if (task.projectId === input.projectId) {
                emit.next({ type: 'created', task });
              }
            };
 
            const onUpdated = (task: any) => {
              if (task.projectId === input.projectId) {
                emit.next({ type: 'updated', task });
              }
            };
 
            const onDeleted = (data: { id: string }) => {
              emit.next({ type: 'deleted', task: { id: data.id } });
            };
 
            this.tasksService.onTaskCreated(onCreated);
            this.tasksService.onTaskUpdated(onUpdated);
            this.tasksService.onTaskDeleted(onDeleted);
 
            return () => {
              // Cleanup listeners
            };
          });
        }),
    });
  }
}

This router demonstrates:

  • Complex authorization logic
  • Input validation with Zod
  • Proper error handling
  • Real-time subscriptions
  • Business logic separation

React Client Implementation

Build a complete task management UI:

client/components/TaskBoard.tsx
import { trpc } from '../trpc-react';
import { TaskStatus, TaskPriority } from '../../src/models/task.model';
import { useState } from 'react';
 
export function TaskBoard({ projectId }: { projectId: string }) {
  const utils = trpc.useUtils();
  
  // Fetch tasks
  const { data: tasks, isLoading } = trpc.tasks.list.useQuery({ projectId });
  
  // Fetch project stats
  const { data: stats } = trpc.tasks.getProjectStats.useQuery({ projectId });
  
  // Subscribe to real-time updates
  trpc.tasks.onTaskUpdate.useSubscription(
    { projectId },
    {
      onData(update) {
        // Invalidate queries to refetch
        utils.tasks.list.invalidate();
        utils.tasks.getProjectStats.invalidate();
      },
    }
  );
 
  // Mutations
  const createTask = trpc.tasks.create.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate();
      utils.tasks.getProjectStats.invalidate();
    },
  });
 
  const updateTask = trpc.tasks.update.useMutation({
    onMutate: async (variables) => {
      // Optimistic update
      await utils.tasks.list.cancel();
      
      const previousTasks = utils.tasks.list.getData({ projectId });
      
      utils.tasks.list.setData({ projectId }, (old) =>
        old?.map(task =>
          task.id === variables.id
            ? { ...task, ...variables }
            : task
        )
      );
      
      return { previousTasks };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      if (context?.previousTasks) {
        utils.tasks.list.setData({ projectId }, context.previousTasks);
      }
    },
    onSettled: () => {
      utils.tasks.list.invalidate();
    },
  });
 
  if (isLoading) return <div>Loading tasks...</div>;
 
  const tasksByStatus = {
    [TaskStatus.TODO]: tasks?.filter(t => t.status === TaskStatus.TODO) || [],
    [TaskStatus.IN_PROGRESS]: tasks?.filter(t => t.status === TaskStatus.IN_PROGRESS) || [],
    [TaskStatus.IN_REVIEW]: tasks?.filter(t => t.status === TaskStatus.IN_REVIEW) || [],
    [TaskStatus.DONE]: tasks?.filter(t => t.status === TaskStatus.DONE) || [],
  };
 
  return (
    <div className="task-board">
      {/* Stats Dashboard */}
      <div className="stats">
        <div>Total: {stats?.total}</div>
        <div>Overdue: {stats?.overdue}</div>
        <div>
          Urgent: {stats?.byPriority[TaskPriority.URGENT]}
        </div>
      </div>
 
      {/* Kanban Board */}
      <div className="columns">
        {Object.entries(tasksByStatus).map(([status, statusTasks]) => (
          <div key={status} className="column">
            <h3>{status.replace('_', ' ')}</h3>
            <div className="tasks">
              {statusTasks.map(task => (
                <TaskCard
                  key={task.id}
                  task={task}
                  onUpdate={(data) =>
                    updateTask.mutate({ id: task.id, ...data })
                  }
                />
              ))}
            </div>
          </div>
        ))}
      </div>
 
      {/* Create Task Form */}
      <CreateTaskForm
        projectId={projectId}
        onSubmit={(data) => createTask.mutate(data)}
      />
    </div>
  );
}
 
function TaskCard({ task, onUpdate }: any) {
  const priorityColors = {
    [TaskPriority.LOW]: 'gray',
    [TaskPriority.MEDIUM]: 'blue',
    [TaskPriority.HIGH]: 'orange',
    [TaskPriority.URGENT]: 'red',
  };
 
  return (
    <div className="task-card" style={{ borderLeft: `4px solid ${priorityColors[task.priority]}` }}>
      <h4>{task.title}</h4>
      <p>{task.description}</p>
      
      <div className="task-meta">
        {task.dueDate && (
          <span>Due: {new Date(task.dueDate).toLocaleDateString()}</span>
        )}
        {task.tags.map(tag => (
          <span key={tag} className="tag">{tag}</span>
        ))}
      </div>
 
      <select
        value={task.status}
        onChange={(e) => onUpdate({ status: e.target.value })}
      >
        {Object.values(TaskStatus).map(status => (
          <option key={status} value={status}>
            {status.replace('_', ' ')}
          </option>
        ))}
      </select>
    </div>
  );
}
 
function CreateTaskForm({ projectId, onSubmit }: any) {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [priority, setPriority] = useState(TaskPriority.MEDIUM);
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit({
      projectId,
      title,
      description,
      priority,
      tags: [],
    });
    setTitle('');
    setDescription('');
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Task title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        required
      />
      <textarea
        placeholder="Description"
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        required
      />
      <select value={priority} onChange={(e) => setPriority(e.target.value as TaskPriority)}>
        {Object.values(TaskPriority).map(p => (
          <option key={p} value={p}>{p}</option>
        ))}
      </select>
      <button type="submit">Create Task</button>
    </form>
  );
}

This implementation showcases:

  • Real-time updates via subscriptions
  • Optimistic UI updates
  • Error handling with rollback
  • Complex state management
  • Full type safety throughout

Performance Optimization

Request Deduplication

tRPC automatically deduplicates identical requests:

ts
// These three identical requests result in only one network call
const [result1, result2, result3] = await Promise.all([
  trpc.posts.getById.query({ id: '123' }),
  trpc.posts.getById.query({ id: '123' }),
  trpc.posts.getById.query({ id: '123' }),
]);

Prefetching Data

Prefetch data before it's needed:

Prefetching
function ProjectsList() {
  const utils = trpc.useUtils();
  
  return (
    <div>
      {projects.map(project => (
        <div
          key={project.id}
          onMouseEnter={() => {
            // Prefetch tasks when hovering
            utils.tasks.list.prefetch({ projectId: project.id });
          }}
        >
          <Link to={`/projects/${project.id}`}>{project.name}</Link>
        </div>
      ))}
    </div>
  );
}

Infinite Queries

Implement pagination efficiently:

Infinite scroll
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  trpc.tasks.list.useInfiniteQuery(
    { projectId },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    }
  );
 
return (
  <div>
    {data?.pages.map((page) =>
      page.tasks.map((task) => <TaskCard key={task.id} task={task} />)
    )}
    
    {hasNextPage && (
      <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    )}
  </div>
);

Testing tRPC Procedures

Unit Testing Procedures

tests/tasks.router.spec.ts
import { TasksRouter } from '../src/tasks/tasks.router';
import { TasksService } from '../src/tasks/tasks.service';
import { TRPCService } from '../src/trpc/trpc.service';
import { TRPCError } from '@trpc/server';
 
describe('TasksRouter', () => {
  let router: TasksRouter;
  let tasksService: TasksService;
 
  beforeEach(() => {
    tasksService = new TasksService();
    const trpcService = new TRPCService();
    router = new TasksRouter(trpcService, tasksService);
  });
 
  describe('create', () => {
    it('should create a task successfully', async () => {
      const caller = router.createRouter().createCaller({
        session: { userId: 'user-1', email: 'test@example.com', role: 'user' },
        req: {} as any,
        res: {} as any,
      });
 
      const task = await caller.create({
        title: 'Test Task',
        description: 'Test description',
        projectId: 'project-1',
        priority: 'MEDIUM',
        tags: [],
      });
 
      expect(task).toMatchObject({
        title: 'Test Task',
        description: 'Test description',
        creatorId: 'user-1',
      });
    });
 
    it('should throw error for invalid input', async () => {
      const caller = router.createRouter().createCaller({
        session: { userId: 'user-1', email: 'test@example.com', role: 'user' },
        req: {} as any,
        res: {} as any,
      });
 
      await expect(
        caller.create({
          title: '', // Invalid: empty title
          description: 'Test',
          projectId: 'project-1',
          priority: 'MEDIUM',
          tags: [],
        })
      ).rejects.toThrow();
    });
  });
});

Conclusion

tRPC represents a fundamental shift in how we think about API development in TypeScript monorepos. By leveraging TypeScript's type system, it eliminates entire categories of bugs and dramatically improves developer experience.

The key insights:

tRPC excels when you control both client and server, work in TypeScript, and value type safety over protocol flexibility. Its automatic type inference, minimal boilerplate, and seamless integration with React Query make it ideal for full-stack TypeScript applications.

tRPC struggles with public APIs, multi-language environments, and polyrepo architectures. When you need language-agnostic contracts or team independence, REST or GraphQL remain better choices.

The NestJS implementation demonstrates that tRPC can integrate with enterprise frameworks while maintaining its simplicity. By understanding tRPC's strengths and limitations, you can make informed decisions about when it's the right tool for your project.

If you're building a TypeScript monorepo with React or Next.js, tRPC deserves serious consideration. The productivity gains and type safety benefits often outweigh the constraints of TypeScript-only development.


Related Posts