Skip to content

Best Practices

Best practices in TypeScript help you write code that is easy to maintain, easy to grow, and less likely to break. In this section, we will look at patterns that are used in real production projects, good habits for structuring your code, and answers you can give in interviews when asked about working with large codebases. By the end of this section, you will understand how to apply these best practices in your own TypeScript projects.

type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; message: string };

Why this pattern is strong:

  • it clearly shows what a success looks like and what an error looks like
  • it is easy to narrow down the type just by checking the status field
  • it makes handling API responses safer in your UI and in your services
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount;
case "decrement":
return state - action.amount;
default: {
const _never: never = action;
return _never;
}
}
}

This stops silent bugs from happening when someone adds a new action later but forgets to handle it in the switch statement, TypeScript will warn you about it.

Instead of using plain, generic names like string everywhere, it is better to create clear, named type aliases for your domain (the real-world things your app deals with).

type UserId = string;
type Email = string;
interface User {
id: UserId;
email: Email;
}

Even though UserId and Email are still just strings underneath, naming them this way makes your code easier to read and shows clearly what each value actually means.

Pattern 4: Avoid any, Prefer Narrow Contracts

Section titled “Pattern 4: Avoid any, Prefer Narrow Contracts”
function parseJson(input: string): unknown {
return JSON.parse(input);
}

After this, you should check the shape of the data before actually using it, instead of just trusting it blindly. Using unknown here is safer than using any, because unknown forces you to check the type before you can use it.

Keep strict mode on

Never turn off strict checks for the whole project just to “quickly fix” some errors.

Prefer explicit API contracts

Types for requests, responses, and shared models should be clear, and kept in one central place.

Use reusable type utilities

Avoid copying and pasting similar type definitions across many files, reuse them instead.

Write readable types

A clever, short, one-line type is usually less valuable than a type that is clear and easy to maintain.

When you are asked “How do you design TypeScript for scale?”, you can answer using this structure:

  1. Start with a strict tsconfig setup and good linting standards.
  2. Model your real-world objects (domain objects) using interfaces and type aliases.
  3. Use unions and discriminators to handle state and API flows safely.
  4. Use generics to build reusable infrastructure and tools.
  5. Use utility, mapped, and conditional types when you notice the same type logic repeating.
  6. Add exhaustiveness checks so that unhandled cases do not slip through.
  7. Use runtime validation whenever you are dealing with data coming from outside your app.
graph LR A[Domain Model Types] --> B[Service Layer] B --> C[UI or API Handlers] C --> D[Discriminated State] D --> E[Exhaustive Handling] E --> F[Safe Refactoring Over Time]

This diagram shows the overall flow: you start with your domain types, pass them through your service layer, then your UI or API handlers use them, the state is tracked using discriminated unions, every case is handled fully, and over time this all makes it safer to change and refactor your code.