MS

0
Skip to main content

TypeScript Full Stack Best Practices I Follow in Every Project

February 15, 2025 (1y ago)

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 absent

Shared 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 here

Custom 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 production

This 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:

  1. Modify the Prisma schema
  2. Run prisma migrate dev to generate the migration SQL
  3. Review the generated SQL (always review — Prisma sometimes generates destructive operations)
  4. The TypeScript types regenerate automatically via prisma generate
  5. 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:

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:

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.

Related Articles

MS
Manjodh Singh Saran

Full Stack Developer · Ludhiana, India

Read more articles · View portfolio