TypeScript Advanced Patterns for Better Type Safety

4 min read
typescripttype safetypatterns

TypeScript's type system is incredibly powerful, offering features that go far beyond basic type annotations. Let's explore some advanced patterns that will level up your TypeScript skills.

Conditional Types: Types That Make Decisions

Conditional types allow you to create types that change based on conditions, similar to ternary operators in JavaScript:

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

Here's a practical example—creating a type-safe event system:

type EventMap = {
  click: { x: number; y: number };
  input: { value: string };
  submit: { formData: FormData };
};

type EventHandler<K extends keyof EventMap> = (event: EventMap[K]) => void;

function addEventListener<K extends keyof EventMap>(
  eventName: K,
  handler: EventHandler<K>
) {
  // Implementation
}

// Type-safe event handling
addEventListener('click', (event) => {
  console.log(event.x, event.y); // ✓ Correctly typed
});

addEventListener('input', (event) => {
  console.log(event.value); // ✓ Correctly typed
});

Mapped Types: Transform Existing Types

Mapped types allow you to transform properties of an existing type:

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

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

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

type PartialUser = Optional<User>;
// { id?: number; name?: string; email?: string; }

type ImmutableUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }

Here's a more advanced pattern for creating a deep partial type:

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object 
    ? DeepPartial<T[K]> 
    : T[K];
};

interface Settings {
  theme: {
    mode: 'light' | 'dark';
    colors: {
      primary: string;
      secondary: string;
    };
  };
  language: string;
}

const settings: DeepPartial<Settings> = {
  theme: {
    colors: {
      primary: '#000'  // Can partially update nested objects
    }
  }
};

Template Literal Types: String Manipulation at the Type Level

Template literal types let you manipulate string types:

type Route = 'home' | 'about' | 'contact';
type RouteHandler = `handle${Capitalize<Route>}`;
// 'handleHome' | 'handleAbout' | 'handleContact'

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type APIRoute = `${HTTPMethod} ${Endpoint}`;

const route: APIRoute = 'GET /api/users'; // ✓
const invalid: APIRoute = 'PATCH /api/users'; // ✗ Error

Practical example for route generation:

type ResourceName = 'user' | 'post' | 'comment';
type Action = 'create' | 'read' | 'update' | 'delete';
type Permission = `${ResourceName}:${Action}`;

function checkPermission(permission: Permission) {
  // Implementation
}

checkPermission('user:create'); // ✓
checkPermission('user:delete'); // ✓
checkPermission('user:invalid'); // ✗ Type error

Type Guards: Runtime Type Checking

Type guards help TypeScript narrow types at runtime:

interface Cat {
  type: 'cat';
  meow: () => void;
}

interface Dog {
  type: 'dog';
  bark: () => void;
}

type Pet = Cat | Dog;

function isCat(pet: Pet): pet is Cat {
  return pet.type === 'cat';
}

function handlePet(pet: Pet) {
  if (isCat(pet)) {
    pet.meow(); // ✓ TypeScript knows it's a Cat
  } else {
    pet.bark(); // ✓ TypeScript knows it's a Dog
  }
}

Utility Types: Extract and Exclude

Extract only the types you need from a union:

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

type LoadingStatus = Extract<Status, 'loading' | 'success'>;
// 'loading' | 'success'

type NonErrorStatus = Exclude<Status, 'error'>;
// 'idle' | 'loading' | 'success'

// Practical example with API responses
type APIResponse<T> = 
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }
  | { status: 'loading' };

type SuccessResponse<T> = Extract<APIResponse<T>, { status: 'success' }>;
// { status: 'success'; data: T }

Branded Types: Extra Type Safety

Sometimes you need to distinguish between two types that are structurally the same:

type Brand<K, T> = K & { __brand: T };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function getUser(id: UserId) {
  // Implementation
}

function getOrder(id: OrderId) {
  // Implementation
}

const userId = 'user_123' as UserId;
const orderId = 'order_456' as OrderId;

getUser(userId);     // ✓
getUser(orderId);    // ✗ Type error - can't mix up IDs!

Const Assertions: Literal Types

Use const assertions to create deeply immutable, literal-typed values:

const config = {
  endpoints: {
    api: 'https://api.example.com',
    auth: 'https://auth.example.com'
  },
  timeout: 5000
} as const;

// config.endpoints.api is type 'https://api.example.com'
// not string

// This allows for better autocomplete and type safety
type Config = typeof config;
type Endpoint = typeof config.endpoints[keyof typeof config.endpoints];

Conclusion

These advanced TypeScript patterns might seem complex at first, but they provide incredible value in large codebases. They catch bugs at compile-time, improve code completion, and make refactoring safer.

The key is to use these patterns judiciously. Not every piece of code needs advanced types—but when you do need them, TypeScript's type system has you covered.