Skip to main content

Advanced Types

TypeScript’s type system is incredibly powerful — but more importantly, it is practical. Advanced types are not academic curiosities; they are the tools that let you model real business logic at the type level. Think of your basic types (string, number, boolean) as individual LEGO bricks. Advanced types are the techniques for combining, transforming, and constraining those bricks into complex structures that precisely describe your application’s data. A senior engineer would say: “If your types do not model your domain accurately, you are leaving bugs on the table that the compiler could have caught for free.”

1. Union Types (Revisited)

Union types represent values that can be one of several types. Think of a union as a labeled box that can hold different kinds of items — the box itself tells you “this contains either A or B,” and you must check which one before using it. Discriminated unions take this further by putting a tag on the item inside the box so you can identify it instantly.

Exhaustive Checks

This pattern is one of the most valuable defensive programming techniques in TypeScript. By assigning to never in the default branch, you create a compile-time tripwire: if someone adds a new shape variant (e.g., 'pentagon') but forgets to handle it in this switch, the compiler immediately flags the error.
Practical tip: This pattern is critical in codebases where unions grow over time (think API response types, state machine states, or permission levels). Without it, adding a new variant silently falls through to the default case. A senior engineer would say: “Exhaustive checks turn runtime bugs into compile-time errors.”

Intersection with Functions


3. Conditional Types

Conditional types are the if/else of the type system. They let you create types that depend on conditions — “if T is a string, use type X; otherwise use type Y.” This is what makes TypeScript’s type system Turing-complete and enables the kind of type-level programming that powers libraries like Zod, tRPC, and Prisma.

Basic Syntax

Practical Examples

Practical tip: The infer keyword is like a “capture group” in a regex — it extracts a piece from a complex type. You will see it everywhere in library type definitions.

Distributive Conditional Types

When conditional types act on unions, they distribute over each member — meaning the condition is applied to each union member individually, then the results are combined back into a union. This is a common source of confusion, so pay close attention.
Pitfall: Distribution catches many developers off guard. If you write Exclude<'a' | 'b' | 'c', 'a'>, TypeScript checks each member against 'a' individually, yielding 'b' | 'c'. This is useful — but when you do not want it, the tuple wrapper trick is essential.

4. Mapped Types

Transform properties of existing types programmatically. Mapped types are the “find-and-replace” of the type system — they iterate over every property of a type and apply a transformation. This is how TypeScript’s built-in Partial<T>, Readonly<T>, Required<T>, and Pick<T, K> are implemented under the hood.

Basic Mapped Types

Mapped Type Modifiers

Key Remapping (TS 4.1+)


5. Template Literal Types

Create types from string patterns.

Practical Example: CSS Units


6. infer Keyword

Extract types from complex structures. The infer keyword is like a “capture group” in a regular expression — it matches a pattern and captures a piece of the type for you to use. It only works inside conditional types (T extends ... ? ... : ...) and is the key that unlocks most advanced type-level programming.

Advanced infer Patterns


7. Built-in Utility Types

TypeScript provides many utility types out of the box. These are not special syntax — they are all implemented using mapped types, conditional types, and infer under the hood. Understanding them means you can read library type definitions and build your own when needed. Think of utility types as a standard toolbox: Partial is a wrench, Pick is a screwdriver, Omit is a saw — each shapes an existing type into what you need.

Object Utilities

Union Utilities

Function Utilities

String Utilities


8. Custom Utility Types

Build your own utility types. Once you understand mapped types, conditional types, and infer, you can compose them into custom utilities tailored to your domain. These are the types that senior TypeScript developers write to make entire classes of bugs impossible. Each utility below is a real-world pattern you will encounter in production codebases.

Deep Partial

DeepReadonly

Nullable

PickByType

OmitByType


9. Type Challenges

Practice advanced types with these patterns. Type challenges are to TypeScript what algorithmic puzzles are to data structures — they sharpen your ability to think at the type level. The patterns below appear frequently in library source code and senior-level interviews.

Tuple to Union

Union to Intersection

Get Required Keys

Get Optional Keys


10. Type-safe API Patterns

Type-safe Event Emitter

Type-safe Builder


Summary

Next, we’ll take a deep dive into generics!

Interview Deep-Dive

Strong Answer:infer is TypeScript’s way of extracting a type from within a larger type structure. Think of it as pattern matching with a capture group.
  • The problem it solves: Without infer, you cannot decompose complex types. If you have a function type and want its return type, you would need the original definition. infer lets you extract it from any function type, even third-party ones.
  • How ReturnType works: type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never. If T matches a function pattern, capture the return type as R. If not, give never.
  • Step-by-step: ReturnType<() => string> — does () => string match (...args: any[]) => infer R? Yes, R = string. Result: string. For ReturnType<number>: no match, result: never.
  • Beyond functions: type ArrayElement<T> = T extends (infer E)[] ? E : never extracts element types. type Awaited<T> = T extends Promise<infer U> ? U : T unwraps Promises.
infer only works inside the condition of a conditional type. It is TypeScript’s equivalent of destructuring for types.Follow-up: Can you nest ‘infer’ to extract from deeply nested structures?Yes. Use recursion: type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T. This unwraps Promise<Promise<string>> to string. TypeScript has a recursion depth limit (~100 levels) to prevent infinite loops.
Strong Answer:Distributive conditional types are one of TypeScript’s most confusing behaviors.
  • The rule: When a conditional type acts on a naked type parameter receiving a union, TypeScript distributes across each member individually, then combines results.
  • Example: type ToArray<T> = T extends any ? T[] : never. With T = string | number, result is string[] | number[], not (string | number)[].
  • The surprise: type IsString<T> = T extends string ? 'yes' : 'no' with IsString<string | number> gives 'yes' | 'no', not 'no', because distribution evaluates each member separately.
  • Disabling distribution: Wrap in a tuple: [T] extends [string] ? 'yes' : 'no'. Now the union is checked as a whole.
  • Why it exists: Powers utility types like Exclude<T, U> = T extends U ? never : T, which filters union members.
Follow-up: How does Exclude work internally?Exclude<'a' | 'b' | 'c', 'a'> distributes: 'a' matches, becomes never. 'b' and 'c' do not match, pass through. Result: 'b' | 'c'. The never is absorbed in the union.
Strong Answer:Mapped types iterate over keys of a type and transform each property — the type-level for...in loop.
  • Syntax: { [K in keyof T]: Transform<T[K]> } iterates over every key K and produces a transformed property.
  • Building DeepReadonly:
    • Shallow: { readonly [K in keyof T]: T[K] }. Only top-level properties become readonly.
    • Deep: type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] }. Recurse into nested objects.
    • Gotcha: Functions extend object, so exclude them: check T[K] extends Function first.
    • Another gotcha: Arrays need special handling for readonly arrays.
Partial, Required, Readonly, Pick, Omit are all mapped types in TypeScript’s standard library.Follow-up: What do ’+’ and ’-’ modifiers do?They add or remove property modifiers. -readonly removes readonly. -? removes optional (this is how Required<T> works internally). You can combine: { -readonly [K in keyof T]-?: T[K] } makes all properties mutable and required.
Strong Answer:This exercise tests generics, indexed access types, and constrained type parameters together.
  • Event map: interface AppEvents { login: { userId: string }; error: { message: string; code: number }; }. Keys are event names, values are payload types.
  • Generic class: class EventEmitter<T extends Record<string, any>>. The generic is the event map.
  • Type-safe on: on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void. Constrains event names to valid keys. T[K] is the payload type.
  • Type-safe emit: emit<K extends keyof T>(event: K, data: T[K]): void. Checks payload types at compile time.
  • Power: Adding an event is one line in the interface. All call sites are automatically type-checked.
Follow-up: How would you handle unsubscribe?Return a closure from on that removes the callback: on(...): () => void. The caller stores the unsubscribe function. This avoids reference equality bugs with anonymous callbacks that off(event, handler) would require.