tRPC enables end-to-end typesafe APIs in TypeScript applications. Let's explore how to implement tRPC effectively in your full-stack applications.
Understanding tRPC
tRPC provides end-to-end type safety without code generation or schemas. Key benefits include:
- Full TypeScript type inference
- Zero build steps
- Lightweight runtime
- Excellent developer experience
- Built-in caching and batching
Basic Setup
1. Server Configuration
Set up your tRPC server:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const appRouter = router({
hello: publicProcedure
.input(z.string().optional())
.query(({ input }) => {
return `Hello ${input ?? 'World'}`;
}),
createUser: publicProcedure
.input(z.object({
name: z.string(),
email: z.string().email()
}))
.mutation(async ({ input }) => {
// Add user to database
return { success: true };
})
});
export type AppRouter = typeof appRouter;
2. Client Integration
Set up the client:
// client/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/trpc';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
Advanced Patterns
1. Middleware Implementation
Create custom middleware for authentication:
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Not authenticated'
});
}
return next({
ctx: {
user: ctx.user
}
});
});
const protectedProcedure = t.procedure.use(isAuthed);
export const appRouter = router({
getProfile: protectedProcedure
.query(({ ctx }) => {
return ctx.user;
}),
updateProfile: protectedProcedure
.input(z.object({
name: z.string(),
bio: z.string()
}))
.mutation(async ({ input, ctx }) => {
// Update user profile
return { success: true };
})
});
2. Error Handling
Implement robust error handling:
class CustomError extends TRPCError {
constructor(message: string) {
super({
code: 'INTERNAL_SERVER_ERROR',
message,
cause: new Error(message)
});
}
}
const errorHandler = t.middleware(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new CustomError('An unexpected error occurred');
}
});
Performance Optimization
1. Query Caching
Implement efficient query caching:
const withCache = t.middleware(async ({ path, type, next }) => {
if (type !== 'query') {
return next();
}
const cacheKey = `trpc:${path}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const result = await next();
await redis.set(cacheKey, JSON.stringify(result), 'EX', 3600);
return result;
});
2. Batching Requests
Optimize multiple requests:
const batchedRouter = router({
users: publicProcedure
.input(z.array(z.string()))
.query(async ({ input }) => {
const users = await db.user.findMany({
where: {
id: {
in: input
}
}
});
return users.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {});
})
});
Real-World Implementation
1. Database Integration
Implement Prisma with tRPC:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const userRouter = router({
create: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string()
}))
.mutation(async ({ input }) => {
return prisma.user.create({
data: input
});
}),
getAll: publicProcedure
.query(async () => {
return prisma.user.findMany();
})
});
2. File Upload Handling
Handle file uploads:
const uploadRouter = router({
uploadFile: protectedProcedure
.input(z.object({
file: z.any(),
type: z.enum(['image', 'document'])
}))
.mutation(async ({ input, ctx }) => {
const buffer = Buffer.from(input.file);
const fileName = `${ctx.user.id}-${Date.now()}`;
await uploadToStorage(buffer, fileName, input.type);
return {
fileName,
url: generateFileUrl(fileName, input.type)
};
})
});
Testing
1. Unit Testing
Create comprehensive tests:
import { inferProcedureInput } from '@trpc/server';
import { appRouter } from './router';
import { createInnerTRPCContext } from './context';
describe('User Router', () => {
const ctx = createInnerTRPCContext({
session: null,
prisma: prismaMock
});
it('creates a user', async () => {
type Input = inferProcedureInput<
typeof appRouter._def.procedures.user.create
>;
const input: Input = {
email: 'test@example.com',
name: 'Test User'
};
const result = await appRouter.user.create({
input,
ctx
});
expect(result).toMatchObject(input);
});
});
2. Integration Testing
Test the full stack:
describe('tRPC Integration', () => {
let server: any;
beforeAll(async () => {
server = await createTestServer();
});
afterAll(async () => {
await server.close();
});
it('performs end-to-end request', async () => {
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: `http://localhost:${server.port}/trpc`
})
]
});
const result = await client.hello.query('Test');
expect(result).toBe('Hello Test');
});
});
Best Practices
- Type Safety
- Leverage Zod for input validation
- Use strict TypeScript configuration
- Maintain consistent types across layers
- Performance
- Implement proper caching
- Use batching for multiple queries
- Optimize database queries
- Security
- Implement proper authentication
- Validate all inputs
- Handle errors gracefully
Conclusion
tRPC provides a powerful way to build type-safe APIs:
- Benefits
- End-to-end type safety
- Excellent developer experience
- High performance
- Implementation Tips
- Start with basic setup
- Add middleware as needed
- Implement proper error handling
- Use caching for performance
- Best Practices
- Maintain clean architecture
- Write comprehensive tests
- Follow security guidelines
Remember to:
- Keep procedures focused
- Handle errors properly
- Implement proper validation
- Maintain test coverage