Welcome back, CoddyKit crew! In our journey through the world of tRPC, we've explored its powerful introduction and dipped our toes into best practices. Today, in the third installment of our series, we're going to tackle a crucial aspect of mastering any technology: understanding and avoiding common mistakes. Even with tRPC's end-to-end type safety, there are still ways to inadvertently introduce issues, compromise performance, or make your codebase harder to maintain. But fear not! By recognizing these pitfalls, you can steer clear of them and build even more robust, efficient, and enjoyable APIs.

Let's dive into some of the most common tRPC missteps and, more importantly, how to sidestep them.

1. The Siren Song of any or unknown Types

Mistake: Bypassing Type Safety with Loose Typing

One of tRPC's biggest selling points is its unparalleled end-to-end type safety. It ensures that your frontend and backend speak the exact same language, catching errors at compile time rather than runtime. However, it's surprisingly easy to undermine this by liberally using any or unknown types, especially when dealing with complex data structures or third-party integrations.

Why it's a mistake: Using any completely nullifies tRPC's type safety for that particular part of your code. You lose all the benefits of compile-time checks, making refactoring risky and debugging harder. While unknown is safer than any (requiring explicit type narrowing), overusing it without proper checks can still lead to runtime errors if not handled meticulously.

Example of the mistake:

// Bad: Using 'any' in a tRPC procedure
const appRouter = trpc.router<Context>().query('getUserData', {
  resolve: async ({ ctx }) => {
    // Imagine this fetches data from a third-party API
    const data: any = await ctx.someExternalService.fetchUser();
    return data; // Frontend now receives 'any'
  },
});

How to Avoid It: Embrace Zod and Explicit Type Inference

The solution here is to lean into TypeScript and tRPC's strengths. For validating inputs and outputs, Zod is your best friend. It allows you to define schemas that not only validate data at runtime but also infer TypeScript types, giving you the best of both worlds.

  • For inputs: Always use Zod schemas with .input().
  • For outputs: Let tRPC infer the return type, or explicitly define it using Zod's .output() if you need runtime validation for the output (e.g., when integrating with external services whose types you don't fully control).
  • For external data: When fetching data from external APIs, define a Zod schema to parse and validate the incoming data, then use .parse() to ensure it conforms to your expected type.

Corrected example:

import { z } from 'zod';

// Good: Define a Zod schema for the expected user data
const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  // ... other user fields
});

const appRouter = trpc.router<Context>().query('getUserData', {
  resolve: async ({ ctx }) => {
    const rawData = await ctx.someExternalService.fetchUser();
    // Validate and parse the raw data into a type-safe object
    const userData = userSchema.parse(rawData);
    return userData; // Frontend now receives type-safe 'typeof userSchema'
  },
});

2. The N+1 Problem: Inefficient Data Fetching

Mistake: Repeated Database Queries or API Calls

A common performance killer in many applications is the N+1 problem. This occurs when you make one query to retrieve a list of items, and then N additional queries (one for each item) to fetch related data. In the context of tRPC, this often manifests in resolvers that iterate over a list and perform an individual database lookup or API call for each item.

Why it's a mistake: It leads to a large number of database round trips or external API calls, significantly increasing latency and resource consumption, especially as your data grows.

Example of the mistake:

// Bad: N+1 problem in a tRPC procedure
const appRouter = trpc.router<Context>().query('getPostsWithAuthors', {
  resolve: async ({ ctx }) => {
    const posts = await ctx.db.post.findMany(); // 1 query
    const postsWithAuthors = [];
    for (const post of posts) {
      const author = await ctx.db.user.findUnique({ where: { id: post.authorId } }); // N queries
      postsWithAuthors.push({ ...post, author });
    }
    return postsWithAuthors;
  },
});

How to Avoid It: Batching and Data Loaders

The most effective way to combat the N+1 problem is through batching and caching, often facilitated by a library like DataLoader (originally from GraphQL, but highly effective with tRPC). DataLoader collects all individual requests for a given type over a short period and dispatches them in a single batch query, then caches the results.

Alternatively, many ORMs provide built-in ways to eager load related data (e.g., .include() in Prisma, .populate() in Mongoose).

Corrected example (using Prisma's include):

// Good: Eager loading related data
const appRouter = trpc.router<Context>().query('getPostsWithAuthors', {
  resolve: async ({ ctx }) => {
    const posts = await ctx.db.post.findMany({
      include: { author: true }, // Eager load the author relationship
    });
    return posts;
  },
});

Corrected example (conceptual with DataLoader):

// In your context file (e.g., createContext.ts)
import DataLoader from 'dataloader';

const createUserDataLoader = (db: PrismaClient) =>
  new DataLoader(async (ids: readonly string[]) => {
    const users = await db.user.findMany({ where: { id: { in: ids as string[] } } });
    const userMap = new Map(users.map(user => [user.id, user]));
    return ids.map(id => userMap.get(id));
  });

export async function createContext() {
  const db = new PrismaClient();
  return {
    db,
    userLoader: createUserDataLoader(db),
  };
}

// In your tRPC procedure
const appRouter = trpc.router<Context>().query('getPostsWithAuthors', {
  resolve: async ({ ctx }) => {
    const posts = await ctx.db.post.findMany();
    const authorIds = posts.map(post => post.authorId);
    const authors = await ctx.userLoader.loadMany(authorIds); // Batched call

    return posts.map((post, index) => ({
      ...post,
      author: authors[index],
    }));
  },
});

3. Improper Context Management

Mistake: Overloading the Context or Making it Untyped

The tRPC context is a powerful mechanism for providing shared resources (like database connections, authentication details, user sessions) to all your procedures. However, it's easy to misuse it by either making it too heavy with unnecessary objects or failing to properly type it.

Why it's a mistake: A bloated context can slow down request processing and obscure what's truly available. An untyped context defeats the purpose of tRPC's type safety, forcing you to use any or as assertions within your resolvers.

Example of the mistake:

// Bad: Untyped, potentially bloated context
export async function createContext({ req, res }: CreateNextContextOptions) {
  // Imagine doing heavy auth logic here for every request
  const user = await getUserFromSession(req);
  const db = new PrismaClient();
  const analyticsClient = new AnalyticsClient(); // Maybe not needed for every request
  return {
    req,
    res,
    db,
    user,
    analyticsClient,
    // ... many other things
  };
}

// In a procedure, you might have to cast:
const appRouter = trpc.router<any>().query('me', {
  resolve: ({ ctx }) => {
    const user = (ctx as any).user; // Ugh!
    return user;
  },
});

How to Avoid It: Lazy Initialization and Explicit Typing

Define your context type explicitly and initialize expensive resources lazily. Only put what's absolutely necessary for every request directly into the context. For resources that are only needed by specific procedures, consider creating them within those procedures or using a factory pattern.

Corrected example:

// Good: Type-safe and lazily initialized context
import { type inferAsyncReturnType } from '@trpc/server';
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { PrismaClient } from '@prisma/client';

// Define your database client once per server instance
const prisma = new PrismaClient();

export async function createContext({ req, res }: CreateNextContextOptions) {
  const getUser = async () => {
    // Only fetch user if actually needed by a procedure
    // This could involve decoding a JWT, looking up a session, etc.
    // Return null or undefined if no user is authenticated
    return null; // For example
  };

  return {
    req,
    res,
    prisma, // Database client is always available
    getUser, // Function to lazily get user data
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

// In your tRPC procedure
const appRouter = trpc.router<Context>().query('me', {
  resolve: async ({ ctx }) => {
    const user = await ctx.getUser(); // Lazily fetch user
    if (!user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }
    return user;
  },
});

4. Not Validating Input on the Server

Mistake: Relying Solely on Client-Side Type Inference

While tRPC gives you amazing end-to-end type safety, it's crucial to remember that client-side types are not a substitute for server-side input validation. A malicious user or a misbehaving client can always send invalid data to your API, bypassing any client-side type checks.

Why it's a mistake: Lack of server-side validation is a major security vulnerability. It can lead to corrupted data, database injection attacks, unexpected application behavior, or even server crashes.

Example of the mistake:

// Bad: Missing server-side validation (even if client-side types look good)
const appRouter = trpc.router<Context>().mutation('updateUserEmail', {
  input: z.object({ id: z.string(), email: z.string().email() }), // This is good!
  resolve: async ({ ctx, input }) => {
    // What if 'input' was somehow manipulated before reaching here?
    // The Zod validation on 'input' *is* the server-side validation here, but
    // the mistake would be if you *didn't* have this Zod validation at all.
    // Let's illustrate a case where 'email' was not part of the schema, but passed anyway.
    await ctx.db.user.update({
      where: { id: input.id },
      data: { email: input.email }, // Trusting input.email implicitly if no Zod schema was used for it
    });
    return { success: true };
  },
});

How to Avoid It: Always Use Zod for Input Validation

The good news is that tRPC, by design, encourages server-side validation through its .input() method. The mistake often lies in not using it, or using a very loose schema. Always define a strict Zod schema for all your procedure inputs. This ensures that any data reaching your business logic has already been validated against your defined rules.

Corrected example: (The previous "bad" example actually uses Zod correctly for input, which is how tRPC prevents this mistake. The "mistake" is neglecting to use .input() or using a weak schema.)

import { z } from 'zod';

// Good: Always define robust Zod schemas for all inputs
const appRouter = trpc.router<Context>().mutation('updateUserEmail', {
  input: z.object({
    id: z.string().uuid('Invalid user ID format'), // Strict ID validation
    email: z.string().email('Invalid email address'), // Strict email validation
    // Add more validations as needed (e.g., .min(), .max(), .refine())
  }),
  resolve: async ({ ctx, input }) => {
    // At this point, 'input' is guaranteed to be valid according to the schema
    await ctx.db.user.update({
      where: { id: input.id },
      data: { email: input.email },
    });
    return { success: true };
  },
});

5. Overly Granular or Monolithic Procedures

Mistake: Too Many Tiny Procedures or Too Few Bloated Ones

Deciding on the right granularity for your tRPC procedures can be tricky. Some developers create a separate procedure for every single database operation (e.g., getUser, getUsers, createUser, updateUser, deleteUser). Others might bundle too much logic into a single massive procedure.

Why it's a mistake:

  • Too many tiny procedures: Can lead to excessive network requests from the client, unnecessary boilerplate, and a fragmented API.
  • Too few monolithic procedures: Can make procedures hard to read, maintain, and reuse. It also means clients might fetch more data than they need or trigger heavy operations when only a small piece of functionality is desired.

Example of the mistake (monolithic):

// Bad: Monolithic procedure doing too much
const appRouter = trpc.router<Context>().mutation('processOrder', {
  input: z.object({ orderId: z.string() }),
  resolve: async ({ ctx, input }) => {
    // Step 1: Fetch order details
    const order = await ctx.db.order.findUnique({ where: { id: input.orderId } });
    // Step 2: Validate inventory for each item
    // Step 3: Update inventory levels
    // Step 4: Process payment with external provider
    // Step 5: Send confirmation email
    // Step 6: Log analytics event
    // ... this becomes a huge, hard-to-read function
    return { success: true, orderId: input.orderId };
  },
});

How to Avoid It: Logical Grouping and Focused Responsibilities

Aim for a balance. Group related operations logically. A good rule of thumb is that a procedure should do one thing and do it well. Consider the client's perspective: what logical "action" or "piece of data" does the client need?

  • Queries: Can be more granular, focusing on specific data needs (e.g., getPostById, listPostsByTag).
  • Mutations: Should represent a single, atomic business operation (e.g., createPost, updatePostStatus, submitOrder). Complex workflows can be orchestrated by the client calling multiple tRPC mutations or by encapsulating internal, reusable functions within your backend service layer.

Corrected example (refactored monolithic):

// Good: Breaking down monolithic procedure into focused ones
const orderRouter = trpc.router<Context>().mutation('submitOrder', {
  input: z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number() })) }),
  resolve: async ({ ctx, input }) => {
    // This mutation focuses on creating the order and initiating processing
    const newOrder = await ctx.db.order.create({ data: { ...input, status: 'PENDING' } });
    // Delegate complex, potentially asynchronous tasks to a service layer or message queue
    await ctx.orderService.processOrder(newOrder.id);
    return { success: true, orderId: newOrder.id };
  },
});

const adminRouter = trpc.router<Context>().mutation('updateOrderStatus', {
  input: z.object({ orderId: z.string(), status: z.enum(['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED']) }),
  resolve: async ({ ctx, input }) => {
    const updatedOrder = await ctx.db.order.update({
      where: { id: input.orderId },
      data: { status: input.status },
    });
    return { success: true, order: updatedOrder };
  },
});

// ... and so on for other operations like payment processing, email sending etc.
// These can be internal service calls, not necessarily exposed as tRPC procedures.

6. Neglecting Error Handling and Reporting

Mistake: Generic Errors or Silently Failing

Proper error handling is vital for both a good user experience and effective debugging. A common mistake is to either throw generic errors that don't provide useful information to the client or, worse, to silently fail without logging or notifying anyone.

Why it's a mistake: Generic errors make it hard for the client to react appropriately (e.g., displaying a specific message). Silent failures lead to mysterious bugs, data inconsistencies, and a frustrating development experience.

Example of the mistake:

// Bad: Generic error handling
const appRouter = trpc.router<Context>().mutation('deleteUser', {
  input: z.object({ id: z.string() }),
  resolve: async ({ ctx, input }) => {
    try {
      await ctx.db.user.delete({ where: { id: input.id } });
      return { success: true };
    } catch (error) {
      // This catches everything and returns a generic 500
      throw new Error('Failed to delete user'); 
    }
  },
});

How to Avoid It: Use TRPCError and Implement Robust Logging

tRPC provides the TRPCError class, which allows you to throw errors with specific HTTP-like codes and messages that are propagated to the client. This enables your frontend to display user-friendly messages and handle different error scenarios gracefully. Additionally, always implement server-side logging to capture detailed error information for debugging.

Corrected example:

import { TRPCError } from '@trpc/server';
import { z } from 'zod';

const appRouter = trpc.router<Context>().mutation('deleteUser', {
  input: z.object({ id: z.string() }),
  resolve: async ({ ctx, input }) => {
    try {
      const user = await ctx.db.user.findUnique({ where: { id: input.id } });
      if (!user) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
      }
      await ctx.db.user.delete({ where: { id: input.id } });
      return { success: true, message: 'User deleted successfully.' };
    } catch (error) {
      if (error instanceof TRPCError) {
        throw error; // Re-throw tRPC errors as they are
      }
      // Log the unexpected error for server-side debugging
      console.error('Failed to delete user:', error);
      throw new TRPCError({ // Throw a generic server error for unexpected issues
        code: 'INTERNAL_SERVER_ERROR',
        message: 'An unexpected error occurred during user deletion.',
      });
    }
  },
});

Conclusion

tRPC offers an incredibly powerful and enjoyable developer experience, but like any robust tool, understanding its common pitfalls is key to truly mastering it. By consciously avoiding loose typing, optimizing data fetching, meticulously managing your context, ensuring rigorous server-side validation, designing well-scoped procedures, and implementing comprehensive error handling, you'll build tRPC APIs that are not only type-safe but also performant, secure, and a joy to maintain. Keep experimenting, keep learning, and keep building amazing things with CoddyKit!