TypeScript has become the backbone of every full stack project I ship. Whether I'm building a React Native app, a Next.js dashboard, or a Node.js API, TypeScript is the one constant that ties everything together. Over four and a half years of professional development, I've refined a set of TypeScript best practices that catch bugs before they hit production, speed up onboarding for new developers, and make refactoring far less terrifying.
This isn't a beginner's guide to TypeScript. If you're looking for a roadmap, check out my full stack developer roadmap for 2025. This post is about the specific patterns, configurations, and architectural decisions I use in real production codebases — the kind I build at Truxo.ai and in my freelance work.
Why TypeScript for Full Stack Development
Before diving into specifics, let me address why TypeScript dominates full stack development in 2025. The answer is simple: shared types between frontend and backend eliminate an entire category of bugs.
When your API returns a User object and your React component expects a User object, those two types should be the same type, defined once. Not a loosely matching interface on the frontend that drifts out of sync after the third sprint. TypeScript makes this possible.
I've seen teams burn entire weeks debugging issues that boiled down to a mismatched field name between client and server. A createdAt on the backend and a created_at on the frontend. A number where the client expected a string. TypeScript, configured properly, makes these bugs impossible.
tsconfig: The Foundation Everything Else Builds On
Every TypeScript project starts with tsconfig.json, and most developers never touch it beyond the defaults. That's a mistake. Your tsconfig is the foundation that determines how much protection TypeScript actually gives you.
Here's the base configuration I start every project with:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"target": "ES2022",
"module": "ESNext",
"jsx": "react-jsx",
"incremental": true
}
}Let me break down why each of these matters.
strict: true — Non-Negotiable
The strict flag enables a bundle of checks including strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, and noImplicitThis. Every single one of these catches real bugs.
I've seen codebases that start with strict: false "to move fast" and then spend months trying to enable it later. The migration is painful because you're retroactively fixing thousands of type errors that accumulated while you were "moving fast." Start strict. Stay strict.
noUncheckedIndexedAccess: The Most Underrated Flag
This is the single most impactful flag that most developers don't know about. When you access an array element or an object with an index signature, TypeScript normally assumes the value exists. With this flag, it adds undefined to the return type.
const users: string[] = ["Alice", "Bob"];
const third = users[2]; // type: string | undefined (with flag)
// const third = users[2]; // type: string (without flag — dangerous!)
if (third) {
console.log(third.toUpperCase()); // safe
}This single flag has prevented more runtime crashes in my production apps than any other configuration option. Array index out of bounds is one of the most common categories of runtime errors in JavaScript, and this flag forces you to handle it.
exactOptionalPropertyTypes: Precision Matters
This flag distinguishes between a property that's undefined and a property that's missing entirely. It sounds pedantic until you're debugging a database query where { name: undefined } sets a field to null but omitting name leaves the existing value.
interface UpdateUser {
name?: string;
email?: string;
}
// With exactOptionalPropertyTypes: true
const update: UpdateUser = { name: undefined }; // ERROR
const update2: UpdateUser = {}; // OK — name is simply absentShared Types: The Full Stack Superpower
The biggest advantage of TypeScript in a full stack project is type sharing between frontend and backend. Here's how I structure it.
Monorepo Package Structure
In monorepo setups (which I use for most projects — see my SaaS brick factory management case study for a real example), I create a shared package:
packages/
shared/
src/
types/
user.ts
order.ts
api.ts
validators/
user.ts
order.ts
constants/
index.ts
package.json
tsconfig.json
apps/
web/ # Next.js frontend
api/ # Node.js backend
mobile/ # React Native app
The shared package exports types, validators, and constants that every app consumes. When I change a type in shared, TypeScript immediately flags every consumer that needs updating.
API Contract Types
I define API contracts as types that both the server and client import:
// packages/shared/src/types/api.ts
export interface ApiResponse<T> {
success: boolean;
data: T;
error?: {
code: string;
message: string;
details?: Record<string, string[]>;
};
meta?: {
page: number;
pageSize: number;
total: number;
};
}
export interface CreateOrderRequest {
customerId: string;
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
shippingAddress: Address;
notes?: string;
}
export interface CreateOrderResponse {
orderId: string;
status: "pending" | "confirmed";
estimatedDelivery: string;
}The server implements these types. The client expects these types. If either side drifts, the build breaks. That's exactly what you want.
Zod: Runtime Validation That Generates Types
TypeScript types disappear at runtime. They can't validate data coming from an API, a form submission, a WebSocket message, or a database query. That's where Zod comes in, and it's become an essential part of my TypeScript toolkit.
The Pattern: Schema First, Type Second
Instead of writing a type and then a separate validator, I write a Zod schema and infer the type from it:
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
role: z.enum(["admin", "user", "viewer"]),
settings: z.object({
notifications: z.boolean().default(true),
theme: z.enum(["light", "dark", "system"]).default("system"),
}).optional(),
});
// Type is inferred — no manual interface needed
export type CreateUserInput = z.infer<typeof CreateUserSchema>;Now CreateUserSchema validates at runtime and CreateUserInput provides compile-time type safety. One source of truth. Zero drift.
Zod in API Routes
Every API endpoint in my projects validates incoming data with Zod before doing anything else:
// app/api/users/route.ts (Next.js)
import { CreateUserSchema } from "@shared/validators/user";
export async function POST(request: Request) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json({
success: false,
error: {
code: "VALIDATION_ERROR",
message: "Invalid input",
details: result.error.flatten().fieldErrors,
},
}, { status: 400 });
}
// result.data is fully typed as CreateUserInput
const user = await createUser(result.data);
return Response.json({ success: true, data: user });
}This pattern eliminates the "garbage in" problem. By the time your business logic runs, you know the data is exactly what you expect.
Zod with Forms
On the frontend, the same Zod schema powers form validation. With React Hook Form and @hookform/resolvers/zod, you get type-safe forms with zero duplication:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { CreateUserSchema, type CreateUserInput } from "@shared/validators/user";
function CreateUserForm() {
const form = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
defaultValues: {
role: "user",
settings: { notifications: true, theme: "system" },
},
});
// form.handleSubmit gives you validated, typed data
}Same schema validates on both sides. The user sees client-side errors instantly. The server re-validates because you never trust the client. But the validation logic is defined once.
Prisma Types: Database to Frontend Type Safety
Prisma generates TypeScript types from your database schema, which means your type safety chain extends all the way from the database to the UI.
Generated Types Are Your API
When you define a Prisma model:
model Order {
id String @id @default(cuid())
status OrderStatus
customerId String
customer Customer @relation(fields: [customerId], references: [id])
items OrderItem[]
total Decimal
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}Prisma generates types like Order, OrderStatus, and Prisma.OrderCreateInput. I use these generated types in my shared package to build API response types:
import type { Order, OrderItem, Customer } from "@prisma/client";
// Compose the exact shape your API returns
export type OrderWithDetails = Order & {
items: OrderItem[];
customer: Pick<Customer, "id" | "name" | "email">;
};The Prisma.validator Pattern
For complex queries, Prisma's validator utility ensures your select/include objects match your types:
import { Prisma } from "@prisma/client";
const orderWithDetails = Prisma.validator<Prisma.OrderDefaultArgs>()({
include: {
items: true,
customer: { select: { id: true, name: true, email: true } },
},
});
export type OrderWithDetails = Prisma.OrderGetPayload<typeof orderWithDetails>;This guarantees that if you change the query, the type updates automatically. No manual syncing.
Error Handling Patterns That Scale
Error handling is where most TypeScript codebases fall apart. Developers either throw untyped errors everywhere or ignore errors entirely. Here's the pattern I use.
Typed Result Pattern
Instead of throwing errors, I return typed results:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Domain-specific errors
type OrderError =
| { code: "NOT_FOUND"; orderId: string }
| { code: "INSUFFICIENT_STOCK"; productId: string; available: number }
| { code: "PAYMENT_FAILED"; reason: string };
async function createOrder(
input: CreateOrderInput
): Promise<Result<Order, OrderError>> {
const stock = await checkStock(input.items);
if (!stock.sufficient) {
return {
success: false,
error: {
code: "INSUFFICIENT_STOCK",
productId: stock.productId,
available: stock.available,
},
};
}
const order = await db.order.create({ data: input });
return { success: true, data: order };
}The caller is forced to handle both cases. TypeScript's narrowing makes this ergonomic:
const result = await createOrder(input);
if (!result.success) {
switch (result.error.code) {
case "INSUFFICIENT_STOCK":
// TypeScript knows result.error has productId and available here
return showStockError(result.error.productId, result.error.available);
case "PAYMENT_FAILED":
return showPaymentError(result.error.reason);
case "NOT_FOUND":
return show404(result.error.orderId);
}
}
// result.data is Order hereCustom Error Classes with Type Guards
For libraries and shared utilities, I use custom error classes with type guards:
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = "AppError";
}
static isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, "NOT_FOUND", 404, { resource, id });
this.name = "NotFoundError";
}
}
export class ValidationError extends AppError {
constructor(details: Record<string, string[]>) {
super("Validation failed", "VALIDATION_ERROR", 400, { fields: details });
this.name = "ValidationError";
}
}Utility Types I Use Constantly
Beyond the basics, there are a few utility types I reach for in almost every project:
// Make specific properties required
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
// Make specific properties optional
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Extract non-nullable values
type NonNullableFields<T> = {
[K in keyof T]: NonNullable<T[K]>;
};
// Strongly typed Object.keys
function typedKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
// Strongly typed Object.entries
function typedEntries<T extends Record<string, unknown>>(
obj: T
): Array<[keyof T, T[keyof T]]> {
return Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
}These eliminate the need for type assertions (as) in common patterns, which makes your code safer.
Type-Safe Environment Variables
Untyped process.env access is a ticking time bomb. Here's how I handle it:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = envSchema.parse(process.env);
// Now env.DATABASE_URL is string (not string | undefined)
// env.PORT is number (not string)
// Missing or invalid env vars crash at startup, not at 3am in productionThis pattern means your app either starts with valid configuration or crashes immediately with a clear error message. Far better than discovering a missing API key when a user triggers a specific code path.
Strict Typing for React Components
In my React and React Native work (see my React Native production guide for more), I follow specific patterns for component typing:
Discriminated Union Props
When a component behaves differently based on a prop, use discriminated unions instead of optional props:
// Bad: confusing optional props
interface ButtonProps {
onClick?: () => void;
href?: string;
external?: boolean;
}
// Good: clear variants
type ButtonProps =
| { variant: "button"; onClick: () => void }
| { variant: "link"; href: string; external?: boolean }
| { variant: "submit"; formId: string };
function Button(props: ButtonProps) {
switch (props.variant) {
case "button":
return <button onClick={props.onClick}>...</button>;
case "link":
return <a href={props.href} target={props.external ? "_blank" : undefined}>...</a>;
case "submit":
return <button type="submit" form={props.formId}>...</button>;
}
}Generic Components
For reusable components like lists and selectors, generics prevent the need for type assertions:
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getKey: (option: T) => string;
}
function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
return (
<select
value={getKey(value)}
onChange={(e) => {
const selected = options.find((o) => getKey(o) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map((option) => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage — fully typed, no assertions needed
<Select
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(u) => u.name}
getKey={(u) => u.id}
/>TypeScript with Prisma Migrations
One area where type safety often breaks down is database migrations. Here's my workflow:
- Modify the Prisma schema
- Run
prisma migrate devto generate the migration SQL - Review the generated SQL (always review — Prisma sometimes generates destructive operations)
- The TypeScript types regenerate automatically via
prisma generate - TypeScript errors now point to every file that needs updating
This creates a cascade of compiler errors that acts as a checklist. Fix them all and your full stack app is consistent from database to UI.
Performance Considerations
TypeScript's type system is Turing-complete, which means it's possible to write types that take minutes to check. Here are rules I follow to keep builds fast:
- Avoid deep recursive types unless absolutely necessary
- Use
interfaceovertypefor object shapes — interfaces are faster for the compiler - Enable
incremental: truein tsconfig for faster rebuilds - Use
skipLibCheck: trueto avoid re-checking node_modules types - Limit union type members — unions with 50+ members are a code smell
- Profile with
--generateTracewhen builds slow down
In large monorepos, I also use project references (references in tsconfig) to enable incremental builds across packages.
Common Mistakes I See in Production Codebases
After reviewing dozens of TypeScript codebases, these are the patterns I most commonly need to fix:
Using any as an escape hatch: Every any is a hole in your type safety. Use unknown instead and narrow with type guards.
Not validating external data: Types don't exist at runtime. Always validate API responses, form inputs, and environment variables.
Overly complex generic types: If a type requires a Ph.D. to read, simplify it. Types exist to help developers, not to prove cleverness.
Ignoring strict null checks: The number of production crashes caused by Cannot read property of undefined is staggering. Strict null checks prevent them.
String enums for everything: Use union types ("pending" | "active" | "cancelled") for simple cases. Reserve enums for when you need reverse mapping.
My TypeScript Stack in 2025
For reference, here's the TypeScript-centric stack I'm currently using across my projects:
- Frontend: Next.js 14 + TypeScript + Tailwind
- Mobile: React Native (Expo) + TypeScript
- Backend: Node.js + TypeScript (or FastAPI + Python for ML-heavy work)
- Database: PostgreSQL + Prisma
- Validation: Zod everywhere
- API Layer: tRPC (for monorepos) or typed REST with shared contracts
- Testing: Vitest + Testing Library
- CI: TypeScript strict checks in CI pipeline — no build = no merge
You can see this stack in action across the projects on my portfolio. Every project listed there follows these patterns.
Closing Thoughts
TypeScript isn't just about adding types to JavaScript. It's about building a system where the compiler catches mistakes before your users do. The practices I've outlined here aren't theoretical — they're the result of years of shipping full stack applications and learning what breaks in production.
If you're starting your TypeScript journey, focus on three things: enable strict mode, use Zod for runtime validation, and share types between frontend and backend. Everything else builds on that foundation.
For more on how these patterns fit into a broader development career, check out more articles on my blog. And if you're curious about the specific technologies I recommend for 2025, my full stack developer roadmap covers the complete picture.
I'm Manjodh Singh Saran — a full stack developer based in Ludhiana, India, building production software with TypeScript across web and mobile. If you have questions about any of these patterns, feel free to reach out.