Technology8 min read

Modern TypeScript Patterns for Large-Scale Applications

This article explores advanced TypeScript patterns that we use in production to build more reliable, maintainable applications. We cover discriminated unions, branded types, type predicates, and more.

Marcus Webb

Marcus Webb

Head of Product

Modern TypeScript Patterns for Large-Scale Applications

Introduction

TypeScript has evolved significantly since its introduction. Modern TypeScript offers powerful features that, when used effectively, can dramatically improve code quality and developer experience.

This article explores advanced patterns we use in production applications.

Discriminated Unions

Discriminated unions are one of TypeScript's most powerful features for modeling complex state.

The Pattern

A discriminated union uses a common property (the discriminant) to distinguish between different variants of a type:

type LoadingState = { status: 'loading' }

type SuccessState = { status: 'success'; data: User[] }

type ErrorState = { status: 'error'; error: Error }

type State = LoadingState | SuccessState | ErrorState

Benefits

This pattern enables exhaustive checking, ensuring you handle all possible states:

function render(state: State) {

switch (state.status) {

case 'loading':

return <Spinner />

case 'success':

return <UserList users={state.data} />

case 'error':

return <ErrorMessage error={state.error} />

}

}

Branded Types

Branded types add nominal typing to TypeScript's structural type system.

The Problem

Consider two string types that are semantically different:

type UserId = string

type PostId = string

function getPost(postId: PostId): Post { ... }

const userId: UserId = 'user_123'

getPost(userId) // No error! But this is wrong.

The Solution

Branded types prevent this mixing:

type UserId = string & { readonly brand: unique symbol }

type PostId = string & { readonly brand: unique symbol }

const userId = 'user_123' as UserId

getPost(userId) // Error!

Type Predicates

Type predicates enable custom type guards that narrow types based on runtime checks.

Basic Usage

function isUser(value: unknown): value is User {

return (

typeof value === 'object' &&

value !== null &&

'id' in value &&

'email' in value

)

}

Integration with Arrays

Type predicates work beautifully with array methods:

const items: (User | null)[] = [...]

const users: User[] = items.filter((item): item is User => item !== null)

Template Literal Types

Template literal types enable string manipulation at the type level.

API Route Types

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'

type Route = '/users' | '/posts' | '/comments'

type Endpoint = ${Method} ${Route}

// Endpoint = 'GET /users' | 'GET /posts' | ...

Conditional Types

Conditional types enable complex type transformations.

Extracting Types

type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T

type Result = Awaited<Promise<Promise<string>>> // string

Conclusion

These patterns represent just a fraction of what modern TypeScript offers. The key is to choose patterns that improve clarity and safety without adding unnecessary complexity.

Start by identifying the specific problems you're facing—type safety issues, unclear state modeling, or mixing of semantically different values—and apply the appropriate pattern to address them.

Frequently Asked Questions