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


  • [TypeScript Handbook - Advanced Types](https://www.typescriptlang.org/docs/handbook/2/types-from-types.html)
  • [Type Challenges](https://github.com/type-challenges/type-challenges)
  • [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/)

  • 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.