Asher Cohen
Back to posts

TypeScript Tips and Best Practices

Practical TypeScript tips — ReturnType, discriminated unions, mapped types, readonly, exhaustiveness checks, and predicate type guards

Introduction

TypeScript's type system is incredibly powerful. These practical tips cover patterns that make your types more precise, your code safer, and your development experience better.

ReturnType

Extract the return type of a function automatically:

// Instead of manually typing the return value
function getUser() {
  return { id: 1, name: 'Alice', email: 'alice@example.com' };
}

// ✅ Use ReturnType
type User = ReturnType<typeof getUser>;
// { id: number; name: string; email: string; }

// Practical: keep return type in sync automatically
function createAction(type: string, payload: unknown) {
  return { type, payload, timestamp: Date.now() };
}
type Action = ReturnType<typeof createAction>;

Discriminated Unions

Use literal types as discriminators for type-safe branching:

// ✅ Discriminated union
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2; // radius is typed!
    case 'square':
      return shape.side ** 2;             // side is typed!
    case 'rectangle':
      return shape.width * shape.height;  // width/height typed!
  }
}

// ❌ Avoid discriminated unions for global action types
// They create coupling across the entire codebase

The in Operator for Type Narrowing

interface User {
  name: string;
  email: string;
}

interface Admin {
  name: string;
  email: string;
  permissions: string[];
}

function processPerson(person: User | Admin) {
  // ✅ Narrow with 'in' operator
  if ('permissions' in person) {
    console.log(person.permissions); // Admin
  } else {
    console.log(person.email); // User
  }
}

Mapped Types

Transform existing types programmatically:

// Basic mapped type
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

// More examples
type Optional<T> = {
  [K in keyof T]?: T[K];
};

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Index signature
type StringMap = {
  [index: string]: string;
};

// Key remapping (TS 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

Deep Readonly

Prevent mutations at all levels of nested objects:

type DeepReadonlyObject<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};

type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonlyObject<E>>
  : T extends object
    ? DeepReadonlyObject<T>
    : T;

// Usage — perfect for Redux state
interface RootState {
  user: { name: string; preferences: { theme: string } };
  items: { id: number; data: string }[];
}

type ReadonlyRootState = DeepReadonly<RootState>;

// Now all nested properties are readonly
declare const state: ReadonlyRootState;
// state.user.name = 'new'; // Error!
// state.items[0].data = 'new'; // Error!

Exhaustiveness Checks with never

Ensure switch statements handle all cases:

type Status = 'idle' | 'loading' | 'success' | 'error';

function handleStatus(status: Status): string {
  switch (status) {
    case 'idle':
      return 'Ready';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Done!';
    case 'error':
      return 'Failed';
    default:
      // ✅ Exhaustiveness check — compile error if new status added
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

Conditional Types

Create types that depend on conditions:

// Basic conditional type
type IsString<T> = T extends string ? 'yes' : 'no';
type A = IsString<string>;  // 'yes'
type B = IsString<number>;  // 'no'

// Extract promise value
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type C = Unwrap<Promise<string>>; // string

// Filter union types
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

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

type UserStringKeys = StringKeys<User>; // 'name' | 'email'

Predicate Type Guards

Custom type narrowing functions:

// ✅ Predicate type guard
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'name' in obj &&
    'email' in obj
  );
}

// Usage
const data: unknown = fetchData();
if (isUser(data)) {
  console.log(data.name); // TypeScript knows this is User
}

// Array filter with predicate
const items: (User | Admin)[] = getItems();
const users = items.filter(isUser); // User[]

Module Augmentation

Extend existing types from libraries:

// Augment Express Request
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
      requestId: string;
    }
  }
}

// Augment existing interfaces
interface Window {
  analytics: {
    track: (event: string, data?: unknown) => void;
  };
}

Best Practices Summary

Do's

// ✅ Use ReturnType for derived types
type Result = ReturnType<typeof myFunction>;

// ✅ Use discriminated unions for variant types
type Response = { status: 'ok'; data: unknown } | { status: 'error'; message: string };

// ✅ Use mapped types for transformations
type Partial<T> = { [K in keyof T]?: T[K] };

// ✅ Use predicate guards for type narrowing
function isError(value: unknown): value is Error { return value instanceof Error; }

// ✅ Use never for exhaustiveness checks
default: const _: never = value;

Don'ts

// ❌ Don't use discriminated unions for global types
// Creates unnecessary coupling

// ❌ Don't over-use conditional types
// Simple types are easier to understand

// ❌ Don't forget readonly for immutable data
// Especially important for Redux/state management

// ❌ Don't use 'any' as a shortcut
// Use 'unknown' and narrow with type guards

// ❌ Don't ignore strict mode
// Enable strict: true in tsconfig.json

Conclusion

These TypeScript patterns make your code safer and more expressive:

  • ReturnType — Keep types in sync with implementations
  • Discriminated unions — Type-safe branching
  • Mapped types — Programmatic type transformations
  • Deep Readonly — Prevent accidental mutations
  • Predicate guards — Custom type narrowing
  • Exhaustiveness checks — Catch missing cases at compile time

Master these and TypeScript becomes a powerful ally rather than a burden.

#typescript #tips #type-system #javascript #web-development