DevAcademy
LearnTypeScriptTypeScript Best Practices
AdvancedTypeScript

TypeScript Best Practices

Learn conventions and habits for writing maintainable, strict TypeScript as a project grows.

Reading Time

16 min

Lesson

Lesson 30 of 30

Start Strict, Stay Strict

Enable "strict": true from day one. Retrofitting strict mode onto a large, loosely-typed codebase later requires fixing hundreds of newly surfaced errors at once — starting strict spreads that cost out naturally as you write each file.

General Guidelines

  • Avoid any — prefer unknown plus narrowing for genuinely unknown data.
  • Let inference handle simple local variables; annotate function parameters explicitly.
  • Prefer discriminated unions over loosely related optional properties.
  • Reach for a utility type (Partial, Pick, Omit) before writing a near-duplicate interface.
  • Use type predicates (is Type) to centralize repeated narrowing logic.
  • Avoid type assertions (as) except at true boundaries, like typed DOM access.

Model Your Domain with Types

Instead of a single loosely-typed object with many optional fields, model distinct states as a discriminated union — it makes invalid states genuinely unrepresentable, not just discouraged by convention.

Loose vs Modeled State

// Loose: many fields could be missing or contradictory
interface RequestState {
  loading: boolean;
  data?: string;
  error?: string;
}

// Modeled: each state is explicit and mutually exclusive
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: string };

Avoid Over-Engineering Types

Not every value needs a named type or a generic utility. A quick inline object type for a single-use function parameter is often clearer than a one-off named type that’s referenced nowhere else.

Validate at the Boundary

TypeScript types disappear at runtime. Data coming from outside your program — API responses, form input, environment variables — should be validated (with a type guard or a library like Zod) at the point it enters your system, not just assumed to match its declared type.

Validating at a Boundary

interface User {
  id: number;
  name: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as User).id === "number" &&
    typeof (value as User).name === "string"
  );
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();

  if (!isUser(data)) {
    throw new Error("Invalid user response from API");
  }

  return data; // safely typed as User from here on
}

A Common Trap: Trusting fetch()’s Type

response.json() is typed as Promise<any> by default — TypeScript will happily let you treat the result as any shape you claim, even if the API actually returns something completely different. Always validate, don’t just assert.

Best Practice

Treat the type system as a tool for making illegal states unrepresentable, not just documentation. The stricter and more precise your types are, the more bugs TypeScript catches for you before your code ever runs.

Summary

Great TypeScript isn’t about typing every single thing exhaustively — it’s about modeling your actual domain accurately, keeping strict mode on, validating data at the edges, and trusting inference for everything else.

Interview Questions

Quick Quiz

1. Why start a project with strict mode enabled from the beginning?

2. Why should data from an external API be validated, not just typed?

3. What is the benefit of modeling state as a discriminated union instead of many optional fields?

Previous