tRPC Complete Guide | End-to-End TypeScript Type Safety

tRPC Complete Guide | End-to-End TypeScript Type Safety

이 글의 핵심

tRPC enables end-to-end type safety between TypeScript client and server without code generation. It provides automatic type inference and excellent DX for full-stack TypeScript apps.

Introduction

tRPC allows you to build fully type-safe APIs without schemas or code generation. It leverages TypeScript’s type inference to provide autocompletion and type safety from server to client.

Traditional API

// Server
app.post('/api/user', (req, res) => {
  const { name, email } = req.body;
  // No type safety!
  const user = db.createUser({ name, email });
  res.json(user);
});

// Client
const res = await fetch('/api/user', {
  method: 'POST',
  body: JSON.stringify({ name: 'Alice', email: '[email protected]' }),
});
const user = await res.json(); // any type!

With tRPC

// Server
const appRouter = router({
  user: {
    create: publicProcedure
      .input(z.object({ name: z.string(), email: z.string().email() }))
      .mutation(({ input }) => {
        return db.createUser(input); // Fully typed!
      }),
  },
});

// Client
const user = await trpc.user.create.mutate({
  name: 'Alice',
  email: '[email protected]',
}); // Fully typed!

1. Installation

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

2. Server Setup

// server/trpc.ts
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const userRouter = router({
  getById: publicProcedure
    .input(z.number())
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input } });
    }),

  list: publicProcedure.query(async () => {
    return await db.user.findMany();
  }),

  create: publicProcedure
    .input(z.object({
      name: z.string(),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    }),
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

3. Server Integration (Next.js)

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
  });

export { handler as GET, handler as POST };

4. Client Setup

// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
// app/layout.tsx
import { TRPCProvider } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

5. Client Usage

'use client';

import { trpc } from '@/utils/trpc';

export function UserList() {
  const { data: users, isLoading } = trpc.user.list.useQuery();
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export function UserProfile({ userId }: { userId: number }) {
  const { data: user } = trpc.user.getById.useQuery(userId);
  
  return <div>{user?.name}</div>;
}

export function CreateUserForm() {
  const createUser = trpc.user.create.useMutation();
  
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    
    await createUser.mutateAsync({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Create</button>
    </form>
  );
}

6. Context and Authentication

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';

export const createContext = async (opts: FetchCreateContextFnOptions) => {
  const token = opts.req.headers.get('authorization');
  const user = await getUserFromToken(token);
  
  return { user };
};

type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create();

// Public procedure (no auth)
export const publicProcedure = t.procedure;

// Protected procedure (requires auth)
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  
  return next({
    ctx: {
      user: ctx.user, // Now guaranteed to be defined
    },
  });
});
// Usage
export const postRouter = router({
  // Anyone can read
  list: publicProcedure.query(async () => {
    return await db.post.findMany();
  }),

  // Must be logged in to create
  create: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return await db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id, // ctx.user is guaranteed
        },
      });
    }),
});

7. Error Handling

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

export const userRouter = router({
  getById: publicProcedure
    .input(z.number())
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input } });
      
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found',
        });
      }
      
      return user;
    }),
});
// Client error handling
function UserProfile({ userId }: { userId: number }) {
  const { data: user, error } = trpc.user.getById.useQuery(userId);
  
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  
  return <div>{user?.name}</div>;
}

8. Batching

// Client automatically batches requests
const [user1, user2, user3] = await Promise.all([
  trpc.user.getById.query(1),
  trpc.user.getById.query(2),
  trpc.user.getById.query(3),
]);

// Single HTTP request with all 3 queries!

9. Subscriptions (WebSockets)

// Server
import { observable } from '@trpc/server/observable';

export const messageRouter = router({
  onMessage: publicProcedure.subscription(() => {
    return observable<{ id: number; text: string }>((emit) => {
      const onMessage = (msg: Message) => {
        emit.next(msg);
      };
      
      eventEmitter.on('message', onMessage);
      
      return () => {
        eventEmitter.off('message', onMessage);
      };
    });
  }),
});
// Client
function MessageList() {
  trpc.message.onMessage.useSubscription(undefined, {
    onData(message) {
      console.log('New message:', message);
    },
  });
  
  return <div>Messages</div>;
}

10. Real-World Example: Blog API

// server/routers/blog.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const blogRouter = router({
  // List posts with pagination
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.number().optional(),
      })
    )
    .query(async ({ input }) => {
      const posts = await db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });
      
      let nextCursor: number | undefined;
      if (posts.length > input.limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }
      
      return {
        posts,
        nextCursor,
      };
    }),

  // Get single post
  getById: publicProcedure
    .input(z.number())
    .query(async ({ input }) => {
      const post = await db.post.findUnique({
        where: { id: input },
        include: { author: true, comments: true },
      });
      
      if (!post) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      
      return post;
    }),

  // Create post (auth required)
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(1),
        tags: z.array(z.string()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return await db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),

  // Update post (auth + ownership check)
  update: protectedProcedure
    .input(
      z.object({
        id: z.number(),
        title: z.string().optional(),
        content: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const post = await db.post.findUnique({ where: { id: input.id } });
      
      if (post?.authorId !== ctx.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      
      return await db.post.update({
        where: { id: input.id },
        data: input,
      });
    }),

  // Delete post
  delete: protectedProcedure
    .input(z.number())
    .mutation(async ({ ctx, input }) => {
      const post = await db.post.findUnique({ where: { id: input } });
      
      if (post?.authorId !== ctx.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      
      await db.post.delete({ where: { id: input } });
      return { success: true };
    }),
});

11. Best Practices

1. Use Zod for Validation

const createUserInput = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().min(0).max(150).optional(),
});

export const userRouter = router({
  create: publicProcedure
    .input(createUserInput)
    .mutation(({ input }) => {
      // input is fully typed and validated!
    }),
});

2. Separate Routers by Domain

server/routers/
├── _app.ts        # Main router
├── user.ts        # User operations
├── post.ts        # Post operations
└── comment.ts     # Comment operations

3. Use Context for Dependencies

export const createContext = async () => {
  return {
    db: prisma,
    redis: redisClient,
    logger: winston,
  };
};

Summary

tRPC provides end-to-end type safety:

  • No code generation - types inferred automatically
  • Full TypeScript integration
  • Excellent DX - autocomplete everywhere
  • Built on React Query for caching
  • WebSocket support for real-time

Key Takeaways:

  1. End-to-end type safety without codegen
  2. Use Zod for input validation
  3. Context for auth and dependencies
  4. Batching for performance
  5. Works great with Next.js

Next Steps:

  • Build with Next.js 15
  • Validate with Zod
  • Query with TanStack Query

Resources: