Skip to content

How to Achieve End-to-End Type Safety in a PERN Stack with TypeScript

Problem

When I started building a PERN stack application (PostgreSQL, Express, React, Node.js), I quickly ran into a frustrating problem: type fragmentation. My backend had TypeScript types for API responses, my frontend had similar but slightly different types for the same data, and keeping them in sync was a nightmare.

Every time I changed an API response structure, I had to manually update the frontend types. Sometimes I’d forget, and the app would crash in production with cryptic “undefined is not a function” errors. I was essentially maintaining two separate type definitions for the same data.

backend/types.ts
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
frontend/types.ts
interface User {
id: number;
name: string;
email: string;
// Oops, forgot createdAt!
}

This duplication violated the DRY principle and created a maintenance burden. I needed a way to share types across the entire stack, ensuring that when I changed something on the backend, the frontend would know about it immediately.

Solution 1: Monorepo with Shared Package

My first approach was restructuring the project as a monorepo. This allowed me to create a shared types package that both frontend and backend could import.

Directory structure
my-pern-app/
├── packages/
│ ├── shared/
│ │ ├── package.json
│ │ └── src/
│ │ ├── types.ts
│ │ └── schemas.ts
│ ├── backend/
│ │ ├── package.json
│ │ └── src/
│ │ └── index.ts
│ └── frontend/
│ ├── package.json
│ └── src/
│ └── App.tsx
├── package.json
└── pnpm-workspace.yaml

I defined all shared types in the shared package:

packages/shared/src/types.ts
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
export type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;

Then I added runtime validation with Zod to ensure the types matched actual API data:

packages/shared/src/schemas.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.number(),
name: z.string().min(1).max(100),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
});
export const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema.optional(),
error: z.string().optional(),
});

The backend imports these types and schemas:

packages/backend/src/index.ts
import express from 'express';
import {
User,
CreateUserRequest,
UserSchema,
CreateUserSchema,
ApiResponse
} from '@my-app/shared';
const app = express();
app.use(express.json());
app.get('/api/users/:id', async (req, res) => {
const user = await getUserFromDb(parseInt(req.params.id));
// Validate response at runtime
const validated = UserSchema.parse(user);
const response: ApiResponse<User> = {
success: true,
data: validated,
};
res.json(response);
});
app.post('/api/users', async (req, res) => {
// Validate request body
const userData: CreateUserRequest = CreateUserSchema.parse(req.body);
const newUser = await createUserInDb(userData);
res.json({
success: true,
data: UserSchema.parse(newUser),
} as ApiResponse<User>);
});

The frontend imports the same types:

packages/frontend/src/App.tsx
import { User, ApiResponse, CreateUserRequest } from '@my-app/shared';
async function fetchUser(id: number): Promise<User | null> {
const response = await fetch(`/api/users/${id}`);
const json: ApiResponse<User> = await response.json();
if (json.success && json.data) {
return json.data;
}
return null;
}
async function createUser(data: CreateUserRequest): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json: ApiResponse<User> = await response.json();
if (!json.success || !json.data) {
throw new Error(json.error || 'Failed to create user');
}
return json.data;
}

The monorepo approach worked well. Any change to shared types would cause TypeScript errors in both frontend and backend during build, catching issues early.

Solution 2: Shared NPM Package

For projects that cannot use a monorepo structure (perhaps different teams manage frontend and backend), I explored publishing a shared npm package.

shared-types/package.json
{
"name": "@my-org/shared-types",
"version": "1.2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"zod": "^3.0.0"
}
}
shared-types/src/index.ts
export * from './types';
export * from './schemas';

After publishing to npm (or a private registry), both projects install it:

Terminal window
npm install @my-org/shared-types

The backend and frontend import identically:

Backend usage
import { User, UserSchema } from '@my-org/shared-types';
Frontend usage
import { User, ApiResponse } from '@my-org/shared-types';

The challenge with this approach is versioning. When backend publishes a new API version with updated types, the frontend must update its dependency to match. I handled this with semantic versioning and strict peer dependency requirements.

Solution 3: tRPC for Full-Stack Type Safety

The most elegant solution I found was tRPC. It provides end-to-end type safety without needing to manually share types at all. The types are inferred automatically from the backend router.

First, I defined the backend router with tRPC:

backend/trpc/router.ts
import { z } from 'zod';
import { router, publicProcedure } from './trpc';
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const user = await getUserFromDb(input.id);
return user; // TypeScript infers return type
}),
createUser: publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const newUser = await createUserInDb(input);
return newUser;
}),
listUsers: publicProcedure
.query(async () => {
const users = await getAllUsers();
return users;
}),
});
export type AppRouter = typeof appRouter;

The AppRouter type is the key. It contains all type information for the API.

On the frontend, I created a tRPC client:

frontend/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../backend/trpc/router';
export const trpc = createTRPCReact<AppRouter>();

Now the frontend has full type inference:

frontend/src/components/UserList.tsx
import { trpc } from '../trpc/client';
export function UserList() {
// TypeScript knows the exact shape of data
const { data, isLoading, error } = trpc.listUsers.useQuery();
// TypeScript knows input must be { id: number }
const userQuery = trpc.getUser.useQuery({ id: 1 });
// TypeScript knows input must be { name: string, email: string }
const createUser = trpc.createUser.useMutation();
const handleCreate = () => {
createUser.mutate({
name: 'John Doe',
});
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}

The beauty of tRPC is that I never manually defined frontend types. They were automatically inferred from the backend router. If I changed the backend, the frontend would immediately show TypeScript errors.

Type inference example
// Backend returns User with 'createdAt' field
// Frontend automatically knows:
userQuery.data?.createdAt // string
// If backend removes 'createdAt', frontend breaks at compile time

Comparison of Approaches

ApproachProsCons
MonorepoSingle repo, easy refactoring, instant syncRequires monorepo tooling
NPM PackageWorks across repos/teams, clear versioningVersion sync overhead, publishing workflow
tRPCZero manual type definitions, automatic syncLocks you into tRPC, requires both ends to use it

Adding Zod for Runtime Validation

TypeScript only provides compile-time safety. At runtime, API data could still be malformed. I added Zod schemas to validate at the boundaries.

For the monorepo approach:

packages/backend/src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject } from 'zod';
export const validateBody = (schema: AnyZodObject) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
success: false,
error: 'Invalid request body',
details: error,
});
}
};
};
Usage in routes
import { validateBody } from './middleware/validate';
import { CreateUserSchema } from '@my-app/shared';
app.post(
'/api/users',
validateBody(CreateUserSchema),
async (req, res) => {
// req.body is now validated and typed
const newUser = await createUserInDb(req.body);
res.json({ success: true, data: newUser });
}
);

For tRPC, Zod is built-in. The input schemas automatically validate:

tRPC automatic validation
.createUser: publicProcedure
.input(z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email format"),
}))
.mutation(async ({ input }) => {
// input is validated before reaching here
// Invalid requests get 400 BAD_REQUEST automatically
})

Summary

End-to-end type safety in a PERN stack eliminates the frustration of keeping frontend and backend types in sync. I implemented three approaches:

  1. Monorepo with shared package: Best for single-team projects where frontend and backend are developed together. Changes propagate instantly across the codebase.

  2. Shared npm package: Works well when frontend and backend live in separate repositories or are managed by different teams. Requires careful version management.

  3. tRPC: The most developer-friendly option. Types are automatically inferred from the backend, eliminating manual type definitions entirely. Best when both frontend and backend are TypeScript and you are comfortable with the tRPC ecosystem.

Regardless of the approach, adding Zod for runtime validation ensures that even if TypeScript types are correct, the actual data flowing through the API matches expectations.

The result is a development experience where changing backend API responses immediately surfaces errors in the frontend code, catching issues at compile time rather than in production.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments