TypeScript Best Practices for Large Codebases

TypeScript best practices that scale — strict mode, modeling domain types, discriminated unions, runtime validation at boundaries, avoiding any, generics, and project structure.

By Danyal Ahmed - Data Scientist · · 4 min read

TypeScript's value grows with codebase size. On a small project it catches typos; on a large one it becomes documentation, a refactoring safety net and a design tool. But only if you use it well. A TypeScript codebase full of any, type assertions and loose configs gives you the build step without the safety.

These practices come from maintaining large React and Node.js codebases where types have to earn their keep.

Key takeaways

  • Turn on strict mode and friends from day one; retrofitting is painful.
  • Model your domain with precise types: discriminated unions, literal types, branded IDs.
  • Validate at the boundaries. Types vanish at runtime; API responses and user input need runtime validation.
  • Treat any as a bug; prefer unknown and narrow it.
  • Let inference do the work inside functions; be explicit at module boundaries.

1. Use a strict configuration

json{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "verbatimModuleSyntax": true
  }
}

strict enables strictNullChecks, which alone prevents a huge category of "cannot read property of undefined" bugs. noUncheckedIndexedAccess makes arr[i] and record[key] possibly undefined, which matches reality.

2. Model the domain precisely

Types should make invalid states impossible. Instead of optional-field soup:

ts// ❌ Which combinations are valid? Nobody knows.
type Payment = {
  status: string;
  paidAt?: Date;
  failureReason?: string;
  refundId?: string;
};

Use a discriminated union:

ts// ✅ Each state carries exactly the data it needs.
type Payment =
  | { status: "pending"; createdAt: Date }
  | { status: "paid"; createdAt: Date; paidAt: Date }
  | { status: "failed"; createdAt: Date; failureReason: string }
  | { status: "refunded"; createdAt: Date; paidAt: Date; refundId: string };

function describe(p: Payment): string {
  switch (p.status) {
    case "pending":  return "Awaiting payment";
    case "paid":     return `Paid on ${p.paidAt.toLocaleDateString()}`;
    case "failed":   return `Failed: ${p.failureReason}`;
    case "refunded": return `Refunded (${p.refundId})`;
    default: {
      const _exhaustive: never = p;   // compile error if a case is missing
      return _exhaustive;
    }
  }
}

Add a new status and the compiler shows you every place that needs updating. That's refactoring safety you can't get from tests alone.

3. Brand your IDs

Mixing up a userId and an orderId (both strings) is a classic bug. Branded types prevent it:

tstype Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function getOrder(id: OrderId) { /* ... */ }

const userId = "u_123" as UserId;
getOrder(userId); // ❌ compile error

Create branded values in one place, typically your validation layer, to keep the assertions contained.

4. Validate at runtime boundaries

TypeScript types are erased at compile time. This line is a lie the compiler can't catch:

tsconst user = (await res.json()) as User; // trusting the network

Validate data crossing trust boundaries: HTTP responses, request bodies, environment variables, localStorage, message queues. Schema libraries like Zod let you define the schema once and infer the type:

tsimport { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
  createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>;

const user = UserSchema.parse(await res.json()); // throws on bad data

Validate environment variables at startup, too, so a missing secret fails fast at boot instead of at 3 a.m.

5. Avoid any; use unknown

any turns off type checking and spreads silently through your code. When you genuinely don't know a type, use unknown and narrow it:

tsfunction parseError(e: unknown): string {
  if (e instanceof Error) return e.message;
  if (typeof e === "string") return e;
  return "Unknown error";
}

Enforce it with ESLint (@typescript-eslint/no-explicit-any) and review every exception. The same goes for non-null assertions (!) and as casts: each one is a spot where you told the compiler to trust you.

6. Let inference work, but annotate boundaries

Don't annotate what TypeScript already knows:

tsconst total = items.reduce((sum, i) => sum + i.price, 0); // inferred: number

Do annotate exported functions' parameters and return types. Explicit return types on public APIs prevent accidental changes, give better error messages and speed up type checking in large projects.

7. Use utility types instead of duplication

tstype CreateUserInput = Omit<User, "id" | "createdAt">;
type UserPatch = Partial<Pick<User, "email" | "role">>;
type ReadonlyUser = Readonly<User>;
type RoleLabels = Record<User["role"], string>;

Derive types from a single source of truth (your schema or domain type) so changes propagate automatically.

8. Keep generics purposeful

Generics are great for reusable utilities and data structures. They're a smell when they appear in business logic just to seem flexible. Rules of thumb:

  • A type parameter should relate two or more things (input to output, argument to argument).
  • Constrain generics (<T extends { id: string }>) so the function can actually use them.
  • Readability wins. If the signature needs a paragraph to explain, simplify.

9. Prefer satisfies for typed config objects

tsconst routes = {
  home: "/",
  blog: "/blog",
  post: "/blog/:slug",
} satisfies Record<string, `/${string}`>;

routes.post; // type stays the literal "/blog/:slug", not string

satisfies checks the shape without widening the inferred literal types.

10. Structure for scale

  • Organize by feature/domain, not by technical type. billing/ containing its components, hooks, API and types beats a global types/ folder with 300 files. See clean architecture.
  • Share types across the stack in a monorepo package, or generate them from OpenAPI or GraphQL schemas, so frontend and backend can't drift.
  • Use project references or build tooling that type-checks incrementally in large monorepos.
  • Run tsc --noEmit in CI. Type errors should block merges (CI/CD pipeline guide).

11. Types and tests complement each other

Types prove the shape of data; tests prove behavior. Strong types let you delete whole categories of tests (checking that a function handles null when the type forbids it), so you can focus tests on business logic. See our testing strategy guide.

Migration tips for existing JavaScript

  1. Add tsconfig with allowJs and checkJs: false; rename files gradually.
  2. Enable strict for new files or folders first (via separate configs or ESLint rules).
  3. Type the boundaries (API clients, shared utilities) before leaf components.
  4. Track any counts and ratchet them down over time.

FAQ

Is TypeScript worth it for small projects?

Usually yes. The setup cost is small with modern tooling, and editor autocomplete and early error detection pay off quickly. The benefit grows as the project and team grow.

Should I use interface or type?

Both work for object shapes. Interfaces support declaration merging and can give clearer error messages in some cases; type aliases handle unions, intersections and mapped types. Many teams use type by default and interface for extendable public contracts. Consistency matters more than the choice.

Does TypeScript make code slower?

No. Types are erased at compile time, and the emitted JavaScript runs the same. Build-time type checking takes time in large projects, which incremental builds and project references mitigate.

How do I type API responses safely?

Generate types from an OpenAPI or GraphQL schema, and validate responses at runtime with a schema library at the boundary. Never cast res.json() directly to a type you haven't verified.