Home/Blog/Article
Web Development

Mastering TypeScript: Advanced Types and Patterns for Scalable Applications

Explore advanced TypeScript features including conditional types, mapped types, and template literal types. Learn how to build type-safe, scalable applications with real-world examples.

Nov 20, 2025
10 min read

About the Author

M
Michael Chen
Senior TypeScript Engineer

Michael specializes in TypeScript and has contributed to several open-source TypeScript projects. He's passionate about type safety and developer tooling.

Need Expert Help?

Let's discuss how we can help bring your project to life with our web development expertise.

Introduction

TypeScript has evolved from a simple type checker to a sophisticated type system that rivals those found in functional programming languages. In this guide, we'll explore advanced TypeScript patterns that will help you build robust, maintainable applications.

Conditional Types

Conditional types allow you to create types that depend on other types, similar to ternary operators in JavaScript.

Basic Conditional Types

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

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

Practical Example: Extract Function Return Type

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchUser() {
  return { id: 1, name: 'John' };
}

type User = UnwrapPromise<ReturnType<typeof fetchUser>>;
// Result: { id: number; name: string; }

Mapped Types

Mapped types let you transform existing types into new ones by iterating over their properties.

Making Properties Optional or Readonly

type User = {
  id: number;
  name: string;
  email: string;
};

// Make all properties optional
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Make all properties readonly
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

Advanced: Getters for All Properties

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

type UserGetters = Getters<User>;
// Result:
// {
//   getId: () => number;
//   getName: () => string;
//   getEmail: () => string;
// }

Template Literal Types

Create new string literal types by combining existing ones.

Building Event Names

type EventType = 'click' | 'focus' | 'blur';
type Element = 'button' | 'input';

type EventHandler = `on${Capitalize<EventType>}${Capitalize<Element>}`;
// Result: 'onClickButton' | 'onClickInput' | 'onFocusButton' | ...

Type-Safe API Routes

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'users' | 'posts' | 'comments';

type ApiRoute = `/api/${Resource}` | `/api/${Resource}/${string}`;

const validRoute: ApiRoute = '/api/users/123'; // ✅
const invalidRoute: ApiRoute = '/api/invalid'; // ❌ Type error

Discriminated Unions

Create type-safe state machines and API responses.

State Machine Pattern

type LoadingState = {
  status: 'loading';
};

type SuccessState<T> = {
  status: 'success';
  data: T;
};

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

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

function handleState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return state.data; // TypeScript knows data exists
    case 'error':
      return state.error.message; // TypeScript knows error exists
  }
}

Utility Type Patterns

Deep Readonly

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

type Config = {
  database: {
    host: string;
    port: number;
  };
};

const config: DeepReadonly<Config> = {
  database: {
    host: 'localhost',
    port: 5432,
  },
};

// All of these are errors:
config.database = {}; // ❌
config.database.host = 'newhost'; // ❌

Type-Safe Object Keys

function getObjectKeys<T extends object>(obj: T): Array<keyof T> {
  return Object.keys(obj) as Array<keyof T>;
}

const user = { id: 1, name: 'John', email: 'john@example.com' };
const keys = getObjectKeys(user); // Array<'id' | 'name' | 'email'>

Real-World Pattern: Type-Safe Event Emitter

type EventMap = {
  'user:login': { userId: string; timestamp: Date };
  'user:logout': { userId: string };
  'post:created': { postId: string; title: string };
};

class TypedEventEmitter<T extends Record<string, any>> {
  private listeners: Partial<{
    [K in keyof T]: Array<(data: T[K]) => void>;
  }> = {};

  on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(handler);
  }

  emit<K extends keyof T>(event: K, data: T[K]) {
    this.listeners[event]?.forEach((handler) => handler(data));
  }
}

const emitter = new TypedEventEmitter<EventMap>();

// Type-safe handlers
emitter.on('user:login', (data) => {
  console.log(data.userId); // ✅ TypeScript knows the shape
  console.log(data.timestamp); // ✅ TypeScript knows the shape
});

// Type-safe emit
emitter.emit('user:login', {
  userId: '123',
  timestamp: new Date(),
}); // ✅

// This would be a type error:
emitter.emit('user:login', { userId: '123' }); // ❌ Missing timestamp

Best Practices

1. Use unknown Instead of any

// ❌ Bad
function processData(data: any) {
  return data.value; // No type safety
}

// ✅ Good
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as { value: unknown }).value;
  }
  throw new Error('Invalid data');
}

2. Prefer Type Inference

// ❌ Redundant
const numbers: number[] = [1, 2, 3];

// ✅ Better - let TypeScript infer
const numbers = [1, 2, 3];

3. Use Const Assertions for Literal Types

// Without const assertion
const config = {
  endpoint: '/api/users',
  method: 'GET',
};
// Type: { endpoint: string; method: string; }

// With const assertion
const config = {
  endpoint: '/api/users',
  method: 'GET',
} as const;
// Type: { readonly endpoint: '/api/users'; readonly method: 'GET'; }

Performance Considerations

TypeScript's type checking happens at compile time, so complex types don't impact runtime performance. However, overly complex types can slow down IDE performance and compilation.

Tips for Better Performance:

  • Use type aliases for complex types instead of repeating them
  • Avoid deeply nested conditional types when possible
  • Use indexed access types instead of mapping over large unions
  • Consider using separate type files for large type definitions

Conclusion

Advanced TypeScript patterns enable you to catch more bugs at compile time and create more maintainable codebases. Start with the basics like conditional types and mapped types, then gradually incorporate more advanced patterns as your codebase grows.

The key is finding the right balance between type safety and complexity. Not every function needs advanced types, but for critical parts of your application, these patterns can prevent entire categories of bugs.

Further Reading

Stay Updated

Subscribe to Our Newsletter

Get the latest articles, insights, and updates delivered directly to your inbox. Join our community of developers and tech enthusiasts.

We respect your privacy. Unsubscribe at any time.