TypeScript Best Practices for Modern Development in 2025

Master TypeScript with these essential best practices covering type safety, generics, utility types, strict configuration, and patterns that make your codebase more maintainable.

Hero image for TypeScript Best Practices for Modern Development in 2025

TypeScript has become the default choice for serious JavaScript projects. But simply adding TypeScript to a project isn’t enough — you need to use it effectively. Poorly typed TypeScript is worse than no TypeScript at all because it gives a false sense of safety.

This guide covers the patterns and practices that separate good TypeScript from great TypeScript.

Enable Strict Mode

The single most important thing you can do is enable strict mode in your tsconfig.json. It enables a set of type-checking rules that catch the most common bugs.

{
  "compilerOptions": {
    "strict": true,
    // strict enables these flags:
    // "noImplicitAny": true,
    // "strictNullChecks": true,
    // "strictFunctionTypes": true,
    // "strictBindCallApply": true,
    // "strictPropertyInitialization": true,
    // "noImplicitThis": true,
    // "useUnknownInCatchVariables": true,
    // "alwaysStrict": true

    // Additional recommended flags
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Use unknown Instead of any

any is a TypeScript escape hatch that disables type checking. unknown forces you to narrow the type before using it.

// ❌ any disables type checking entirely
function parseJSON(json: string): any {
  return JSON.parse(json);
}
const user = parseJSON('{"name":"Alice"}');
user.nonExistentMethod(); // No error! This will crash at runtime.

// ✅ unknown forces proper type narrowing
function parseJSON(json: string): unknown {
  return JSON.parse(json);
}

const data = parseJSON('{"name":"Alice"}');

// Must narrow before using
if (typeof data === "object" && data !== null && "name" in data) {
  console.log((data as { name: string }).name); // Safe!
}

Discriminated Unions

Model state machines and variants with discriminated unions. They enable exhaustive type checking and make impossible states impossible.

// ❌ Everything is optional, invalid states are possible
interface ApiState {
  loading?: boolean;
  data?: User;
  error?: string;
}

// ✅ Discriminated union - each state is precise
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

type UserState = ApiState<User>;

function renderUser(state: UserState) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserCard user={state.data} />; // TypeScript knows data exists here!
    case 'error':
      return <ErrorMessage message={state.error} />;
    // TypeScript will warn if you don't handle all cases (with noImplicitReturns)
  }
}

Branded Types for Domain Safety

Prevent mixing up values of the same primitive type by using branded types:

// Without branding: These are both `string` and can be accidentally swapped
function sendEmail(to: string, from: string) {
  /* ... */
}
sendEmail(fromEmail, toEmail); // Oops! No TypeScript error.

// With branded types: Type-safe at compile time, zero runtime cost
type Email = string & { readonly _brand: "Email" };
type UserId = string & { readonly _brand: "UserId" };

function createEmail(email: string): Email {
  if (!email.includes("@")) throw new Error("Invalid email");
  return email as Email;
}

function sendEmail(to: Email, from: Email) {
  /* ... */
}

const toEmail = createEmail("alice@example.com");
const userId = "user-123" as UserId;

sendEmail(toEmail, userId); // ✅ TypeScript Error! UserId is not Email
sendEmail(userId, toEmail); // ✅ TypeScript Error!
sendEmail(toEmail, toEmail); // ✅ Works correctly

Generics Done Right

Generics make code reusable without sacrificing type safety:

// ❌ Non-generic: Duplicated for every type
function getFirstUser(items: User[]): User | undefined {
  return items[0];
}
function getFirstPost(items: Post[]): Post | undefined {
  return items[0];
}

// ✅ Generic: Works for any type
function getFirst<T>(items: T[]): T | undefined {
  return items[0];
}

// With constraints: T must have an 'id' field
function findById<T extends { id: string }>(
  items: T[],
  id: string,
): T | undefined {
  return items.find((item) => item.id === id);
}

// With conditional types: The return type changes based on the input
type MaybeArray<T> = T | T[];
function normalizeToArray<T>(value: MaybeArray<T>): T[] {
  return Array.isArray(value) ? value : [value];
}

Master Utility Types

TypeScript’s built-in utility types are incredibly powerful:

interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
  createdAt: Date;
  updatedAt: Date;
}

// Partial: All fields become optional
type UserUpdate = Partial<User>;
// → { id?: string; name?: string; email?: string; ... }

// Required: All fields become required
type FullUser = Required<User>;

// Pick: Select specific fields
type UserPreview = Pick<User, "id" | "name" | "role">;
// → { id: string; name: string; role: 'admin' | 'user' | 'guest' }

// Omit: Exclude specific fields
type UserCreate = Omit<User, "id" | "createdAt" | "updatedAt">;
// → { name: string; email: string; role: 'admin' | 'user' | 'guest' }

// Record: Create a map type
type RolePermissions = Record<User["role"], string[]>;
// → { admin: string[]; user: string[]; guest: string[] }

// ReturnType & Parameters: Infer from functions
type ComponentProps = Parameters<typeof MyComponent>[0];
type QueryResult = ReturnType<typeof useQuery>;

The satisfies Operator

The satisfies operator (TypeScript 4.9+) validates a value matches a type while preserving the narrower inferred type:

type Config = {
  port: number | string;
  host: string;
  debug?: boolean;
};

// ❌ Type annotation loses narrow type
const config: Config = {
  port: 3000,
  host: "localhost",
};
config.port.toFixed(2); // Error! 'toFixed' doesn't exist on 'number | string'

// ✅ satisfies validates AND preserves narrow type
const config = {
  port: 3000,
  host: "localhost",
} satisfies Config;

config.port.toFixed(2); // ✅ Works! TypeScript knows port is number here
config.nonExistent; // ❌ Error! Config doesn't have this property

Template Literal Types

Build precise string types using template literals:

type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// → "onClick" | "onFocus" | "onBlur"

type CSSProperty = `${string}-${string}`;

// Build a type-safe event emitter
type EventMap = {
  userLogin: { userId: string };
  userLogout: { userId: string; reason: string };
  pageView: { path: string; referrer?: string };
};

type EventEmitter = {
  on<K extends keyof EventMap>(
    event: K,
    handler: (payload: EventMap[K]) => void,
  ): void;
  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void;
};

Type Guards and Assertions

Write robust type guards for runtime validation:

// Type predicate: Tells TypeScript what type something is after the check
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as User).id === "string" &&
    typeof (value as User).name === "string" &&
    typeof (value as User).email === "string"
  );
}

// Assertion function: Throws if the condition isn't met
function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error(`Expected User, got: ${JSON.stringify(value)}`);
  }
}

// Usage
const response = await fetch("/api/user");
const data: unknown = await response.json();

if (isUser(data)) {
  // TypeScript narrows `data` to User inside this block
  console.log(data.name.toUpperCase()); // Safe!
}

// Or assert and use directly
assertIsUser(data);
console.log(data.name); // TypeScript knows it's a User here

Avoid These Common Mistakes

Don’t use ! Non-null Assertion Carelessly

// ❌ Lying to TypeScript - will crash if element doesn't exist
const element = document.getElementById("app")!;
element.innerHTML = "Hello";

// ✅ Check properly
const element = document.getElementById("app");
if (!element) throw new Error("#app element not found");
element.innerHTML = "Hello";

Don’t Use as for Type Coercion

// ❌ You're lying to TypeScript about the type
const user = apiResponse as User;

// ✅ Validate first
if (isUser(apiResponse)) {
  const user = apiResponse; // TypeScript narrows correctly
}

Prefer Interfaces for Object Shapes You’ll Extend

// Use interface for object shapes (especially if extended)
interface Animal {
  name: string;
  sound(): string;
}

interface Dog extends Animal {
  breed: string;
}

// Use type for unions, intersections, and computed types
type StringOrNumber = string | number;
type DogOrCat = Dog | Cat;
type Readonly<T> = { readonly [K in keyof T]: T[K] };

Configuration Recommendation

Here’s a robust tsconfig.json for a modern TypeScript project:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

Conclusion

TypeScript’s power comes from using it properly. Enable strict mode from the start, model your domain with discriminated unions, use generics to write reusable yet type-safe code, and rely on utility types to avoid repetition.

The goal isn’t to placate the type checker — it’s to encode your business logic into the type system so TypeScript can catch entire categories of bugs at compile time instead of runtime.

TypeScript done right means fewer bugs, better IDE support, and code that’s self-documenting by design.