Skip to main content
Back to Articles

TypeScript Advanced Types: Generics, Discriminated Unions & Mapped Types

Master TypeScript advanced types, generics, conditional types, and build bulletproof type-safe applications.

March 7, 20269 min readBy Mathematicon

TypeScript Advanced Types: Generics, Discriminated Unions & Mapped Types

The Problem You're Solving

Your function accepts any value but doesn't know what type it is:

// āŒ Bad: No type safety
function processData(data: any) {
  return data.name;  // What if data is a number?
}

// āœ… Good: Type-safe and reusable
function processData<T extends { name: string }>(data: T): string {
  return data.name;  // TypeScript knows data has 'name'
}

That difference = runtime errors vs compile-time type checking.

Without advanced types, you lose TypeScript's power. With them, you write code that's safe, reusable, and self-documenting.

TypeScript mastery appears in 28% of full-stack interviews and prevents 60% of production bugs.

Generics: Reusable Type-Safe Code

Basic Generic Function

// āŒ Limited - Works with any type but type-unsafe
function getFirstElement(arr: any[]): any {
  return arr[0];
}

// āœ… Generic - Type-safe and reusable
function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

// Usage
const num = getFirstElement([1, 2, 3]);  // num is number
const str = getFirstElement(['a', 'b']);  // str is string

Generic Constraints

// āŒ Problem: Can't call .length on unknown type T
function getLength<T>(arr: T[]): number {
  return arr.length;  // OK for arrays
}

getLength('hello');  // Error! string is not T[]

// āœ… Solution 1: Constrain to array
function getLength<T extends any[]>(arr: T): number {
  return arr.length;
}

// āœ… Solution 2: Constrain to has-length property
function getLength<T extends { length: number }>(arr: T): number {
  return arr.length;
}

getLength('hello');  // āœ… OK - string has length
getLength([1, 2, 3]);  // āœ… OK - array has length

Multiple Generics

// Store key-value pairs with type safety
function createEntry<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const entry = createEntry('name', 'Alice');
// entry is [string, string]

const mixed = createEntry(1, true);
// mixed is [number, boolean]

Conditional Types: Logic in Types

// Basic conditional: Type ? TrueType : FalseType
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

Real Example: Extracting Return Types

// Extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
  return `Hello, ${name}`;
}

type GreetReturn = ReturnType<typeof greet>;  // string

Extracting Function Arguments

type Parameters<T> = T extends (...args: infer P) => any ? P : never;

type MyFunctionParams = Parameters<typeof greet>;  // [name: string]

Mapped Types: Transform Types

Basic Mapped Type

// Create a readonly version of any type
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

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

type ReadonlyUser = Readonly<User>;
// Result:
// {
//   readonly name: string;
//   readonly age: number;
// }

Getters and Setters

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

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

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

Discriminated Unions: Type-Safe Pattern Matching

Problem: Ambiguous Union Types

// āŒ Problem: Can't know which shape
type Response = { status: 'success'; data: string } | { status: 'error'; error: string };

function handleResponse(res: Response) {
  console.log(res.data);  // Error! data not on error variant
}

Solution: Discriminated Union

// āœ… Use a discriminator property
type Response =
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

function handleResponse(res: Response) {
  if (res.status === 'success') {
    console.log(res.data);  // āœ… Now valid!
  } else {
    console.log(res.error);  // āœ… Valid for error type
  }
}

Real Example: API Responses

type ApiResponse<T> =
  | { status: 200; data: T }
  | { status: 400; error: string; details: Record<string, string> }
  | { status: 500; error: string };

async function fetchUser(id: number) {
  const res: ApiResponse<{ name: string }> = await fetch(`/api/users/${id}`).then(r => r.json());

  switch (res.status) {
    case 200:
      console.log('User:', res.data.name);  // āœ… Has data
      break;
    case 400:
      console.log('Validation error:', res.details);  // āœ… Has details
      break;
    case 500:
      console.log('Server error:', res.error);  // āœ… Has error
      break;
  }
}

Template Literal Types

// Extract method from string
type ParseRoute<T extends string> = T extends `/${infer R}` ? R : T;

type UserRoute = ParseRoute<'/users'>;  // 'users'

// Create variants
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

type ApiRoute = `${HTTPMethod} /api/${'users' | 'posts' | 'comments'}`;
// Expands to:
// 'GET /api/users' | 'GET /api/posts' | ...

Utility Types: Built-in Helpers

Partial - Make all properties optional

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

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

const update: PartialUser = { name: 'Alice' };  // āœ… Valid

Pick<T, K> - Select specific properties

type UserPreview = Pick<User, 'name' | 'email'>;
// {
//   name: string;
//   email: string;
// }

Omit<T, K> - Exclude specific properties

type UserWithoutAge = Omit<User, 'age'>;
// {
//   name: string;
//   email: string;
// }

Record<K, T> - Object with specific keys

type PageRoles = Record<'admin' | 'user' | 'guest', boolean>;
// {
//   admin: boolean;
//   user: boolean;
//   guest: boolean;
// }

const roles: PageRoles = {
  admin: true,
  user: false,
  guest: false
};

Exclude<T, U> & Extract<T, U> - Union manipulation

type Status = 'success' | 'error' | 'pending';

type ErrorStatus = Extract<Status, 'error'>;  // 'error'
type NonErrorStatus = Exclude<Status, 'error'>;  // 'success' | 'pending'

Common Mistakes

āŒ Mistake 1: Over-constraining Generics

// WRONG - Too restrictive
function processUser<T extends User>(user: T): string {
  return user.name;  // Why use generic if you need User?
}

// CORRECT - Generic only when needed
function processUser<T extends { name: string }>(obj: T): string {
  return obj.name;  // Any object with name works
}

processUser({ name: 'Alice', role: 'admin' });  // āœ…

āŒ Mistake 2: Forgetting as const in Discriminators

// WRONG - status is string, not literal
function createResponse(success: boolean) {
  return {
    status: success ? 'success' : 'error',  // string, not 'success' | 'error'
    data: {}
  };
}

// CORRECT - Use `as const` for literals
function createResponse(success: boolean) {
  return {
    status: success ? 'success' as const : 'error' as const,
    data: {}
  };
}

āŒ Mistake 3: Circular Type References

// WRONG - Infinite recursion
type Node = {
  value: string;
  children: Node[];  // References itself infinitely
};

// CORRECT - Still works! TypeScript handles this
type Node = {
  value: string;
  children: Node[];  // āœ… Valid
};

FAQ: Advanced Types Mastery

Q1: When should I use generics?

A: When you want reusable code that works with multiple types.

// āœ… Good use: Generic wrapper
type ApiResponse<T> = {
  status: number;
  data: T;
};

const userResponse: ApiResponse<User> = { status: 200, data: { name: 'Alice' } };
const postResponse: ApiResponse<Post> = { status: 200, data: { title: 'Hello' } };

// āŒ Bad use: Single type
type UserResponse<T> = {  // Why generic if T is always User?
  status: number;
  data: User;
};

Q2: What's the difference between interface and type?

A: Mostly similar, but subtle differences.

// interface - Can merge, good for objects
interface User {
  name: string;
}
interface User {  // āœ… Merges with above
  age: number;
}
// User now has both name and age

// type - Can't merge, better for unions/tuples
type User = { name: string } | { id: number };  // Union type
type Coordinate = [number, number];  // Tuple

Use: interface for objects, type for unions/advanced types.


Q3: How do I create a type-safe event emitter?

A: Use generics and discriminated unions.

type EventMap = {
  'user:login': { userId: number };
  'user:logout': { timestamp: number };
  'message:send': { text: string };
};

class EventEmitter {
  private listeners: Map<string, Function[]> = new Map();

  on<K extends keyof EventMap>(event: K, listener: (data: EventMap[K]) => void) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)!.push(listener);
  }

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

const emitter = new EventEmitter();

// āœ… Type-safe: data must match event
emitter.on('user:login', (data) => {
  console.log(data.userId);  // āœ… Has userId
});

emitter.emit('user:login', { userId: 123 });  // āœ… Valid
emitter.emit('user:login', { timestamp: 123 });  // āŒ Error! Wrong shape

Q4: Interview Question: Implement a type-safe form validation.

A: Here's production-ready approach:

type FieldValue = string | number | boolean;

type ValidationRule<T> = {
  validate: (value: T) => boolean | Promise<boolean>;
  message: string;
};

type FormSchema = {
  [key: string]: FieldValue;
};

class FormValidator<T extends FormSchema> {
  private rules: Map<keyof T, ValidationRule<any>[]> = new Map();

  addRule<K extends keyof T>(field: K, rule: ValidationRule<T[K]>) {
    if (!this.rules.has(field)) {
      this.rules.set(field, []);
    }
    this.rules.get(field)!.push(rule);
  }

  async validate(data: T): Promise<Map<keyof T, string[]>> {
    const errors: Map<keyof T, string[]> = new Map();

    for (const [field, rules] of this.rules.entries()) {
      const value = data[field];
      const fieldErrors: string[] = [];

      for (const rule of rules) {
        const isValid = await rule.validate(value);
        if (!isValid) {
          fieldErrors.push(rule.message);
        }
      }

      if (fieldErrors.length > 0) {
        errors.set(field, fieldErrors);
      }
    }

    return errors;
  }
}

// Usage
interface LoginForm {
  email: string;
  password: string;
}

const validator = new FormValidator<LoginForm>();
validator.addRule('email', {
  validate: (v) => /\S+@\S+\.\S+/.test(v),
  message: 'Invalid email'
});
validator.addRule('password', {
  validate: (v) => v.length >= 8,
  message: 'Password must be 8+ characters'
});

Q5: Distributed Conditional Types - What are they?

A: Conditionals that distribute over union members.

// Without distributive property
type Flatten<T> = T extends any[] ? T[number] : T;

// With union - applies to each member
type Str = Flatten<string[]>;      // string
type Num = Flatten<(number | boolean)[]>;  // number | boolean

// Wrap in array to prevent distribution
type NonDistributive<T> = T extends any[] ? T[number] : T;

Conclusion

Advanced TypeScript types:

  1. Generics - Write reusable type-safe code
  2. Conditional Types - Add logic to types
  3. Mapped Types - Transform types systematically
  4. Discriminated Unions - Pattern match on types
  5. Template Literals - Type string patterns

Master these and you'll write code that's impossible to use incorrectly.


Learn More

Share this article

Related Articles