api-design trpc typescripttype safe apifull stack typescript

tRPC TypeScript: Complete Guide to Type-Safe APIs

Master tRPC for type-safe APIs in full-stack TypeScript apps. Learn implementation, best practices, and advanced patterns for bulletproof development.

📖 12 min read 📅 April 28, 2026 ✍ By PropTechUSA AI
12m
Read Time
2.3k
Words
17
Sections

Building full-stack applications with TypeScript has revolutionized how we approach type safety, but [API](/workers) boundaries have traditionally remained a weak point where type information gets lost. Enter tRPC—a library that extends TypeScript's type safety across your entire application stack, eliminating the gap between client and server code while maintaining the flexibility of modern API design.

At PropTechUSA.ai, we've leveraged tRPC extensively in our [property](/offer-check) technology solutions to ensure robust communication between our React frontends and Node.js backends, particularly when handling complex property data structures and real-time market [analytics](/dashboards).

Understanding tRPC's Revolutionary Approach to API Design

The Traditional API Problem

Traditional REST and GraphQL APIs require developers to manually maintain type definitions on both client and server sides. This dual maintenance creates opportunities for inconsistencies, runtime errors, and deployment issues that only surface in production.

Consider a typical property listing API endpoint:

typescript
// Server-side type

interface PropertyListing {

id: string;

address: string;

price: number;

bedrooms: number;

bathrooms: number;

listingDate: Date;

}

// Client-side type (manually maintained)

interface ClientPropertyListing {

id: string;

address: string;

price: number;

bedrooms: number;

bathrooms: number;

listingDate: string; // Oops! Date serialization issue

}

This disconnect leads to runtime failures and debugging nightmares.

How tRPC Solves Type Safety

tRPC eliminates this problem by generating client types directly from server implementations. The server becomes the single source of truth for all type information, automatically propagated to clients during development.

typescript
// Server-side procedure definition

const propertyRouter = router({

getProperty: publicProcedure

.input(z.object({ id: z.string() }))

.query(async ({ input }) => {

return await db.property.findUnique({

where: { id: input.id }

});

})

});

// Client automatically gets full type safety

const property = await trpc.getProperty.query({ id: "prop-123" });

// property is fully typed with no manual intervention

The Full-Stack TypeScript Advantage

When implementing tRPC in full-stack TypeScript applications, developers gain end-to-end type safety that extends beyond simple data fetching. Complex business logic, validation rules, and data transformations maintain their type contracts across the entire application boundary.

Core Concepts and Architecture Patterns

Procedures and Routers

tRPC organizes API endpoints as "procedures" grouped within "routers." Procedures come in three varieties: queries for data fetching, mutations for state changes, and subscriptions for real-time updates.

typescript
import { router, publicProcedure } from './trpc';

import { z } from 'zod';

const propertyRouter = router({

// Query procedure

searchProperties: publicProcedure

.input(z.object({

location: z.string(),

priceRange: z.object({

min: z.number(),

max: z.number()

}),

propertyType: z.enum(['house', 'condo', 'apartment'])

}))

.query(async ({ input }) => {

return await searchPropertiesInDatabase(input);

}),

// Mutation procedure

createListing: publicProcedure

.input(z.object({

address: z.string().min(5),

price: z.number().positive(),

description: z.string().max(1000)

}))

.mutation(async ({ input }) => {

return await createPropertyListing(input);

}),

// Subscription procedure

priceUpdates: publicProcedure

.input(z.object({ propertyId: z.string() }))

.subscription(async function* ({ input }) {

// Yield price updates as they occur

for await (const update of getPriceUpdateStream(input.propertyId)) {

yield update;

}

})

});

Input Validation with Zod Integration

tRPC's tight integration with Zod provides runtime validation that doubles as compile-time type information. This integration ensures that invalid data never reaches your business logic while maintaining TypeScript's static analysis benefits.

typescript
const createPropertySchema = z.object({

address: z.string()

.min(10, "Address too short")

.max(200, "Address too long"),

price: z.number()

.positive("Price must be positive")

.max(50000000, "Price exceeds maximum"),

coordinates: z.object({

lat: z.number().min(-90).max(90),

lng: z.number().min(-180).max(180)

}),

amenities: z.array(z.string()).optional(),

availableFrom: z.date().min(new Date(), "Date must be in future")

});

const createProperty = publicProcedure

.input(createPropertySchema)

.mutation(async ({ input }) => {

// input is fully typed and validated

return await propertyService.create(input);

});

Context and Middleware

tRPC's context system provides dependency injection and request-scoped data access. Middleware enables cross-cutting concerns like authentication, logging, and performance monitoring.

typescript
// Context creation

const createContext = async ({ req, res }: CreateContextOptions) => {

const user = await getUserFromToken(req.headers.authorization);

return {

user,

db: prismaClient,

logger: createLogger({ requestId: generateId() })

};

};

// Authentication middleware

const authMiddleware = middleware(({ ctx, next }) => {

if (!ctx.user) {

throw new TRPCError({ code: 'UNAUTHORIZED' });

}

return next({ ctx: { ...ctx, user: ctx.user } });

});

// Protected procedure

const protectedProcedure = publicProcedure.use(authMiddleware);

Implementation Guide and Real-World Examples

Server Setup and Configuration

Setting up a tRPC server involves defining your router structure, configuring context creation, and integrating with your chosen HTTP framework.

typescript
// server/trpc.ts

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

import { z } from 'zod';

import type { Context } from './context';

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

transformer: superjson, // Handles Date, Map, Set serialization

errorFormatter: ({ shape, error }) => {

return {

...shape,

data: {

...shape.data,

zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError

? error.cause.flatten()

: null

}

};

}

});

export const router = t.router;

export const publicProcedure = t.procedure;

export const middleware = t.middleware;

typescript
// server/routers/app.ts

import { router } from '../trpc';

import { propertyRouter } from './property';

import { userRouter } from './user';

import { analyticsRouter } from './analytics';

export const appRouter = router({

property: propertyRouter,

user: userRouter,

analytics: analyticsRouter

});

export type AppRouter = typeof appRouter;

Client Configuration and Usage

Client setup involves creating a typed client instance and configuring it with your preferred HTTP client and caching strategy.

typescript
// client/trpc.ts

import { createTRPCReact } from '@trpc/react-query';

import { httpBatchLink } from '@trpc/client';

import type { AppRouter } from '../server/routers/app';

export const trpc = createTRPCReact<AppRouter>();

// Client configuration

export const trpcClient = trpc.createClient({

links: [

httpBatchLink({

url: 'http://localhost:3000/api/trpc',

headers: async () => {

const token = await getAuthToken();

return token ? { authorization: Bearer ${token} } : {};

}

})

],

transformer: superjson

});

typescript
// components/PropertySearch.tsx

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

export function PropertySearch() {

const [searchParams, setSearchParams] = useState({

location: '',

priceRange: { min: 0, max: 1000000 },

propertyType: 'house' as const

});

const { data: properties, isLoading, error } = trpc.property.searchProperties.useQuery(

searchParams,

{ enabled: searchParams.location.length > 0 }

);

const createListingMutation = trpc.property.createListing.useMutation({

onSuccess: () => {

// Invalidate and refetch properties

trpc.useContext().property.searchProperties.invalidate();

}

});

// Component implementation...

}

Advanced Patterns and Optimizations

For complex applications, tRPC supports advanced patterns like request batching, response caching, and conditional queries.

typescript
// Batch multiple queries

const [properties, user, analytics] = await Promise.all([

trpc.property.searchProperties.query(searchParams),

trpc.user.getProfile.query(),

trpc.analytics.getMarketData.query({ location: 'San Francisco' })

]);

// Infinite queries for pagination

const {

data,

fetchNextPage,

hasNextPage,

isLoading

} = trpc.property.getPropertiesPaginated.useInfiniteQuery(

{ limit: 20 },

{

getNextPageParam: (lastPage) => lastPage.nextCursor

}

);

// Optimistic updates

const utils = trpc.useContext();

const updatePropertyMutation = trpc.property.updateListing.useMutation({

onMutate: async (newData) => {

await utils.property.getProperty.cancel({ id: newData.id });

const previousData = utils.property.getProperty.getData({ id: newData.id });

utils.property.getProperty.setData(

{ id: newData.id },

(old) => ({ ...old, ...newData })

);

return { previousData };

},

onError: (err, newData, context) => {

utils.property.getProperty.setData(

{ id: newData.id },

context?.previousData

);

}

});

💡
Pro TipUse tRPC's built-in request batching to reduce HTTP overhead. Multiple queries executed within a small time window are automatically batched into a single request.

Best Practices and Production Considerations

Error Handling Strategies

Proper error handling in tRPC applications involves both server-side error formatting and client-side error boundary implementation.

typescript
// Server-side error handling

const getPropertyProcedure = publicProcedure

.input(z.object({ id: z.string() }))

.query(async ({ input, ctx }) => {

try {

const property = await ctx.db.property.findUnique({

where: { id: input.id }

});

if (!property) {

throw new TRPCError({

code: 'NOT_FOUND',

message: Property with ID ${input.id} not found

});

}

return property;

} catch (error) {

ctx.logger.error('Failed to fetch property', { error, id: input.id });

if (error instanceof TRPCError) {

throw error;

}

throw new TRPCError({

code: 'INTERNAL_SERVER_ERROR',

message: 'Failed to retrieve property data'

});

}

});

typescript
// Client-side error handling

function PropertyDetails({ id }: { id: string }) {

const { data, error, isLoading } = trpc.property.getProperty.useQuery(

{ id },

{

retry: (failureCount, error) => {

// Don't retry on 404s

if (error.data?.code === 'NOT_FOUND') return false;

return failureCount < 3;

},

onError: (error) => {

toast.error(Failed to load property: ${error.message});

}

}

);

if (isLoading) return <PropertySkeleton />;

if (error?.data?.code === 'NOT_FOUND') return <PropertyNotFound />;

if (error) return <ErrorBoundary error={error} />;

return <PropertyCard property={data} />;

}

Performance Optimization

Optimizing tRPC applications involves strategic use of caching, prefetching, and subscription management.

typescript
// Prefetch critical data

useEffect(() => {

// Prefetch property details when hovering over search results

const prefetchProperty = (id: string) => {

trpc.property.getProperty.prefetch({ id });

};

// Prefetch user's saved properties

if (user) {

trpc.user.getSavedProperties.prefetch();

}

}, [user]);

// Strategic cache invalidation

const createPropertyMutation = trpc.property.createListing.useMutation({

onSuccess: (newProperty) => {

// Update search results cache

utils.property.searchProperties.setData(

searchParams,

(oldData) => oldData ? [newProperty, ...oldData] : [newProperty]

);

// Invalidate related queries

utils.user.getUserListings.invalidate();

utils.analytics.getMarketData.invalidate({ location: newProperty.city });

}

});

Type Safety Best Practices

Maintaining type safety across large tRPC applications requires disciplined schema design and validation strategies.

typescript
// Shared schema definitions

export const propertySchemas = {

base: z.object({

id: z.string().uuid(),

address: z.string().min(5).max(200),

price: z.number().positive(),

createdAt: z.date(),

updatedAt: z.date()

}),

create: z.object({

address: z.string().min(5).max(200),

price: z.number().positive(),

description: z.string().max(2000).optional(),

amenities: z.array(z.enum(['parking', 'gym', 'pool', 'balcony'])).optional()

}),

update: z.object({

id: z.string().uuid(),

price: z.number().positive().optional(),

description: z.string().max(2000).optional(),

amenities: z.array(z.enum(['parking', 'gym', 'pool', 'balcony'])).optional()

}).strict() // Prevent extra properties

};

// Type extraction for use in components

export type Property = z.infer<typeof propertySchemas.base>;

export type CreatePropertyInput = z.infer<typeof propertySchemas.create>;

export type UpdatePropertyInput = z.infer<typeof propertySchemas.update>;

⚠️
WarningAlways version your tRPC schemas when making breaking changes in production. Consider implementing schema versioning strategies for backward compatibility.

Scaling tRPC in Enterprise Applications

As your application grows, tRPC's type-safe approach becomes even more valuable. At PropTechUSA.ai, we've successfully scaled tRPC implementations to handle thousands of concurrent property searches while maintaining sub-100ms response times through strategic caching and query optimization.

The key to successful tRPC adoption lies in embracing its opinionated approach to full-stack TypeScript development. By treating your server as the authoritative source of type information, you eliminate entire categories of integration bugs while accelerating development velocity.

Consider implementing tRPC in your next full-stack TypeScript project, starting with a small feature area and gradually expanding coverage. The initial investment in learning tRPC's patterns pays dividends through reduced debugging time, improved developer confidence, and more robust production deployments.

Ready to implement type-safe APIs in your property technology stack? Explore how PropTechUSA.ai can help you leverage tRPC and other cutting-edge technologies to build scalable, maintainable real estate applications that deliver exceptional user experiences.

🚀 Ready to Build?

Let's discuss how we can help with your project.

Start Your Project →