Learn TypeScript Patterns

107 patterns across 16 categories. Each one shows the convention, a side-by-side example, and why it matters.

Start here

New to responsive design? Follow these five categories in order.

Type Narrowing
6 patterns

Discriminated unions, type guards, typeof, and instanceof for safely accessing properties. You'll hit this when TypeScript complains about a property that might not exist on a union type.

Avoid
function double(value: string | number) {
  // No narrowing, just cast
  return (value as number) * 2;
}

double("hello"); // NaN at runtime
function double(value: string | number) {
  // No narrowing, just cast
  return (value as number) * 2;
}

double("hello"); // NaN at runtime

Prefer
function double(value: string | number) {
  if (typeof value === "number") {
    return value * 2;
  }
  return value.repeat(2);
}

double("hello"); // "hellohello"
function double(value: string | number) {
  if (typeof value === "number") {
    return value * 2;
  }
  return value.repeat(2);
}

double("hello"); // "hellohello"
Generics
8 patterns

Type parameters, constraints, inference, and when to let TypeScript figure it out. You'll hit this when you write a function that should work with multiple types but you keep reaching for `any`.

Avoid
function first(arr: any[]): any {
  return arr[0];
}

const val = first([1, 2, 3]);
// val is 'any', no autocomplete
val.toFixed(2); // No error even if wrong
function first(arr: any[]): any {
  return arr[0];
}

const val = first([1, 2, 3]);
// val is 'any', no autocomplete
val.toFixed(2); // No error even if wrong

Prefer
function first<T>(arr: T[]): T {
  return arr[0];
}

const val = first([1, 2, 3]);
// val is 'number', full autocomplete
val.toFixed(2); // OK, checked by compiler
function first<T>(arr: T[]): T {
  return arr[0];
}

const val = first([1, 2, 3]);
// val is 'number', full autocomplete
val.toFixed(2); // OK, checked by compiler
Utility Types
8 patterns

Partial, Required, Pick, Omit, Record, and Extract for transforming types without rewriting them. You'll hit this when you need a version of an existing type with some fields optional or removed.

Avoid
interface User {
  name: string;
  email: string;
  age: number;
}

function updateUser(id: string, data: User) {
  // Caller must pass ALL fields, even
  // if they only want to update one
}

updateUser("1", {
  name: "Alice",
  email: "alice@example.com",
  age: 30,
}); // Just wanted to update name!
interface User {
  name: string;
  email: string;
  age: number;
}

function updateUser(id: string, data: User) {
  // Caller must pass ALL fields, even
  // if they only want to update one
}

updateUser("1", {
  name: "Alice",
  email: "alice@example.com",
  age: 30,
}); // Just wanted to update name!

Prefer
interface User {
  name: string;
  email: string;
  age: number;
}

function updateUser(id: string, data: Partial<User>) {
  // Caller passes only the fields to update
}

updateUser("1", { name: "Alice" }); // OK
interface User {
  name: string;
  email: string;
  age: number;
}

function updateUser(id: string, data: Partial<User>) {
  // Caller passes only the fields to update
}

updateUser("1", { name: "Alice" }); // OK
Union & Intersection
7 patterns

Union types for alternatives, intersection types for combining, and the `never` type for exhaustive checks. You'll hit this when a value can be one of several shapes and you need to handle all of them.

Avoid
// Overly broad type
function setStatus(status: string) {
  // Any string is accepted
}

setStatus("active");  // OK
setStatus("acitve");  // No error, typo undetected
setStatus("banana");  // No error, nonsense value
// Overly broad type
function setStatus(status: string) {
  // Any string is accepted
}

setStatus("active");  // OK
setStatus("acitve");  // No error, typo undetected
setStatus("banana");  // No error, nonsense value

Prefer
type Status = "active" | "inactive" | "pending";

function setStatus(status: Status) {
  // Only valid values accepted
}

setStatus("active");  // OK
setStatus("acitve");  // Error: typo caught
setStatus("banana");  // Error: not a valid Status
type Status = "active" | "inactive" | "pending";

function setStatus(status: Status) {
  // Only valid values accepted
}

setStatus("active");  // OK
setStatus("acitve");  // Error: typo caught
setStatus("banana");  // Error: not a valid Status
Type Assertions
7 patterns

as const, satisfies, type predicates, and why `as` should be your last resort. You'll hit this when you know more than the compiler but want to prove it safely instead of just overriding.

Avoid
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} as Record<string, Color>;

// Lost the specific keys!
palette.primary;    // Color (not "red")
palette.oops;       // Color (no error for bad key)
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} as Record<string, Color>;

// Lost the specific keys!
palette.primary;    // Color (not "red")
palette.oops;       // Color (no error for bad key)

Prefer
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} satisfies Record<string, Color>;

// Keeps specific keys and values!
palette.primary;    // "red"
palette.oops;       // Error: property doesn't exist
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} satisfies Record<string, Color>;

// Keeps specific keys and values!
palette.primary;    // "red"
palette.oops;       // Error: property doesn't exist
Enums & Literals
7 patterns

String literal unions vs enums, const assertions, and when numeric enums cause trouble. You'll hit this when you need a fixed set of values and can't decide between `type Status = 'active' | 'inactive'` and `enum Status`.

Avoid
enum Color {
  Red = "RED",
  Green = "GREEN",
  Blue = "BLUE",
}

// Must import enum to use values
// Generates runtime JavaScript object
// JSON requires conversion: Color[json.color]
function paint(color: Color) { }
paint(Color.Red);
enum Color {
  Red = "RED",
  Green = "GREEN",
  Blue = "BLUE",
}

// Must import enum to use values
// Generates runtime JavaScript object
// JSON requires conversion: Color[json.color]
function paint(color: Color) { }
paint(Color.Red);

Prefer
type Color = "RED" | "GREEN" | "BLUE";

// No import needed for values
// Zero runtime cost (erased at compile time)
// JSON strings work directly
function paint(color: Color) { }
paint("RED");
type Color = "RED" | "GREEN" | "BLUE";

// No import needed for values
// Zero runtime cost (erased at compile time)
// JSON strings work directly
function paint(color: Color) { }
paint("RED");
Strict Mode
7 patterns

strictNullChecks, noUncheckedIndexedAccess, exactOptionalPropertyTypes, and the flags that catch real bugs. You'll hit this when your code compiles fine but crashes at runtime because a value was unexpectedly undefined.

Avoid
// tsconfig: "strict": false
function getLength(name: string) {
  return name.length;
}

const user = users.find((u) => u.id === id);
// No error, but user might be undefined
console.log(getLength(user.name));
// Runtime: Cannot read property 'name'
// of undefined
// tsconfig: "strict": false
function getLength(name: string) {
  return name.length;
}

const user = users.find((u) => u.id === id);
// No error, but user might be undefined
console.log(getLength(user.name));
// Runtime: Cannot read property 'name'
// of undefined

Prefer
// tsconfig: "strict": true
function getLength(name: string) {
  return name.length;
}

const user = users.find((u) => u.id === id);
// Error: 'user' is possibly 'undefined'
if (user) {
  console.log(getLength(user.name));
}
// tsconfig: "strict": true
function getLength(name: string) {
  return name.length;
}

const user = users.find((u) => u.id === id);
// Error: 'user' is possibly 'undefined'
if (user) {
  console.log(getLength(user.name));
}
Readonly & Immutability
6 patterns

readonly properties, Readonly<T>, ReadonlyArray, and as const for preventing accidental mutations. You'll hit this when a function modifies an array or object it was only supposed to read.

Avoid
interface Config {
  apiUrl: string;
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid state
interface Config {
  apiUrl: string;
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid state

Prefer
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only property
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only property
Function Types
6 patterns

Overloads, generic functions, callback typing, and return type inference. You'll hit this when a function should accept different argument shapes and return different types accordingly.

Avoid
// Callback typed as Function
function fetchData(callback: Function) {
  const data = { name: "Alice" };
  callback(data);
}

// No type safety on the callback
fetchData((data) => {
  // data is 'any'
  console.log(data.nonexistent); // No error
});
// Callback typed as Function
function fetchData(callback: Function) {
  const data = { name: "Alice" };
  callback(data);
}

// No type safety on the callback
fetchData((data) => {
  // data is 'any'
  console.log(data.nonexistent); // No error
});

Prefer
interface User {
  name: string;
}

function fetchData(callback: (data: User) => void) {
  const data: User = { name: "Alice" };
  callback(data);
}

fetchData((data) => {
  console.log(data.name);        // OK
  console.log(data.nonexistent); // Error
});
interface User {
  name: string;
}

function fetchData(callback: (data: User) => void) {
  const data: User = { name: "Alice" };
  callback(data);
}

fetchData((data) => {
  console.log(data.name);        // OK
  console.log(data.nonexistent); // Error
});
Interface vs Type
6 patterns

When to use interface, when to use type, declaration merging, and extending vs intersecting. You'll hit this when you're unsure which to pick and whether it actually matters.

Avoid
type User = {
  name: string;
  role: string;
};

// Intersection: silently merges conflicts
type AdminUser = User & {
  role: number; // No error here!
};

// role is 'string & number' which is 'never'
// You only find out when you try to use it
const admin: AdminUser = {
  name: "Alice",
  role: "admin", // Error: never
};
type User = {
  name: string;
  role: string;
};

// Intersection: silently merges conflicts
type AdminUser = User & {
  role: number; // No error here!
};

// role is 'string & number' which is 'never'
// You only find out when you try to use it
const admin: AdminUser = {
  name: "Alice",
  role: "admin", // Error: never
};

Prefer
interface User {
  name: string;
  role: string;
}

// Extends: catches conflicts immediately
// interface AdminUser extends User {
//   role: number; // Error: not assignable
// }

interface AdminUser extends User {
  permissions: string[];
}

const admin: AdminUser = {
  name: "Alice",
  role: "admin", // OK
  permissions: ["read"],
};
interface User {
  name: string;
  role: string;
}

// Extends: catches conflicts immediately
// interface AdminUser extends User {
//   role: number; // Error: not assignable
// }

interface AdminUser extends User {
  permissions: string[];
}

const admin: AdminUser = {
  name: "Alice",
  role: "admin", // OK
  permissions: ["read"],
};
Mapped Types
6 patterns

Key remapping, conditional types, infer keyword, and building types from other types. You'll hit this when you need to transform every property of an existing type in a systematic way.

Avoid
interface User {
  name: string;
  email: string;
  age: number;
}

// Manually creating an optional version
interface PartialUser {
  name?: string;
  email?: string;
  age?: number;
}
// Must update both when User changes
interface User {
  name: string;
  email: string;
  age: number;
}

// Manually creating an optional version
interface PartialUser {
  name?: string;
  email?: string;
  age?: number;
}
// Must update both when User changes

Prefer
interface User {
  name: string;
  email: string;
  age: number;
}

// Mapped type transforms every property
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Or simply use the built-in:
type AlsoPartialUser = Partial<User>;
// Both auto-update when User changes
interface User {
  name: string;
  email: string;
  age: number;
}

// Mapped type transforms every property
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Or simply use the built-in:
type AlsoPartialUser = Partial<User>;
// Both auto-update when User changes
Template Literals
6 patterns

Template literal types, string manipulation types, and pattern matching on string shapes. You'll hit this when you want the compiler to enforce that a string follows a specific format like `on${string}`.

Avoid
type EventHandler = {
  on: (event: string, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("clck", handleClick);
// Typo not caught, handler never fires
// No error at compile time
type EventHandler = {
  on: (event: string, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("clck", handleClick);
// Typo not caught, handler never fires
// No error at compile time

Prefer
type EventName =
  | `on${"Click" | "Hover" | "Focus"}`
  | `on${"Key" | "Mouse"}${"Down" | "Up"}`;

type EventHandler = {
  on: (event: EventName, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("onClick", handleClick);
// emitter.on("onClck", handleClick);
// Error: not assignable to EventName
type EventName =
  | `on${"Click" | "Hover" | "Focus"}`
  | `on${"Key" | "Mouse"}${"Down" | "Up"}`;

type EventHandler = {
  on: (event: EventName, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("onClick", handleClick);
// emitter.on("onClck", handleClick);
// Error: not assignable to EventName
React + TypeScript
7 patterns

Component props, event handlers, refs, generic components, and polymorphic patterns. You'll hit this when you try to type a React component that forwards refs or accepts an `as` prop.

Avoid
function UserCard(props: any) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>{props.emial}</p>
      {/* Typo: "emial" instead of "email" */}
      {/* No error because props is any */}
    </div>
  );
}

<UserCard name="Alice" />
// Missing email, no error
function UserCard(props: any) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>{props.emial}</p>
      {/* Typo: "emial" instead of "email" */}
      {/* No error because props is any */}
    </div>
  );
}

<UserCard name="Alice" />
// Missing email, no error

Prefer
interface UserCardProps {
  name: string;
  email: string;
  role?: "admin" | "user";
}

function UserCard({ name, email, role = "user" }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      <span>{role}</span>
    </div>
  );
}

// <UserCard name="Alice" />
// Error: missing required prop 'email'
interface UserCardProps {
  name: string;
  email: string;
  role?: "admin" | "user";
}

function UserCard({ name, email, role = "user" }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      <span>{role}</span>
    </div>
  );
}

// <UserCard name="Alice" />
// Error: missing required prop 'email'
Module Types
6 patterns

Declaration files, ambient modules, type augmentation, and global types. You'll hit this when you import a JavaScript library that has no types or need to extend an existing module's types.

Avoid
// utils.js (no types)
export function slugify(text) {
  return text
    .toLowerCase()
    .replace(/\s+/g, "-");
}

// app.ts
import { slugify } from "./utils";
// Error: Could not find a declaration
// file for module './utils'
// slugify is implicitly 'any'
// utils.js (no types)
export function slugify(text) {
  return text
    .toLowerCase()
    .replace(/\s+/g, "-");
}

// app.ts
import { slugify } from "./utils";
// Error: Could not find a declaration
// file for module './utils'
// slugify is implicitly 'any'

Prefer
// utils.d.ts
export declare function slugify(
  text: string
): string;

// Or even better, rename to utils.ts:
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/\s+/g, "-");
}

// app.ts
import { slugify } from "./utils";
// slugify is typed: (text: string) => string
// utils.d.ts
export declare function slugify(
  text: string
): string;

// Or even better, rename to utils.ts:
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/\s+/g, "-");
}

// app.ts
import { slugify } from "./utils";
// slugify is typed: (text: string) => string
Error Handling
6 patterns

Unknown vs any in catch blocks, Result types, type-safe error handling, and assertion functions. You'll hit this when your catch block uses `error.message` and TypeScript says `error` is `unknown`.

Avoid
try {
  await fetchData();
} catch (error) {
  // With useUnknownInCatchVariables: false
  // error is 'any'
  console.log(error.message);
  // Works for Error objects but crashes
  // if a string or number was thrown
  showToast(error.message);
}
try {
  await fetchData();
} catch (error) {
  // With useUnknownInCatchVariables: false
  // error is 'any'
  console.log(error.message);
  // Works for Error objects but crashes
  // if a string or number was thrown
  showToast(error.message);
}

Prefer
try {
  await fetchData();
} catch (error) {
  // error is 'unknown'
  if (error instanceof Error) {
    console.log(error.message);
    showToast(error.message);
  } else {
    console.log("Unknown error:", error);
    showToast("An unexpected error occurred");
  }
}
try {
  await fetchData();
} catch (error) {
  // error is 'unknown'
  if (error instanceof Error) {
    console.log(error.message);
    showToast(error.message);
  } else {
    console.log("Unknown error:", error);
    showToast("An unexpected error occurred");
  }
}
Common Mistakes
8 patterns

Overusing any, unnecessary type assertions, ignoring strict flags, and other TypeScript anti-patterns that compile but break. You'll hit this when TypeScript stops catching bugs it should have caught.

Avoid
function processData(data: any) {
  // No errors, but no safety either
  data.forEach((item: any) => {
    console.log(item.naem); // Typo
    updateRecord(item.id.toFixed()); // id might not be number
  });
}

// any spreads: anything derived from
// an any value is also any
function processData(data: any) {
  // No errors, but no safety either
  data.forEach((item: any) => {
    console.log(item.naem); // Typo
    updateRecord(item.id.toFixed()); // id might not be number
  });
}

// any spreads: anything derived from
// an any value is also any

Prefer
interface DataRecord {
  id: number;
  name: string;
  status: "active" | "inactive";
}

function processData(data: DataRecord[]) {
  data.forEach((item) => {
    console.log(item.name); // Autocomplete
    // item.naem; // Error: typo caught
    updateRecord(item.id.toFixed());
  });
}
interface DataRecord {
  id: number;
  name: string;
  status: "active" | "inactive";
}

function processData(data: DataRecord[]) {
  data.forEach((item) => {
    console.log(item.name); // Autocomplete
    // item.naem; // Error: typo caught
    updateRecord(item.id.toFixed());
  });
}