TypeScript ยท advanced type system

TypeScript Advanced Types: Conditional, Mapped, and Utility Types

Unlock the full power of TypeScript's type system

โ TypeScript's type system goes far beyond simple interfaces and generics. Conditional types, mapped types, and a rich set of utility types allow you to build complex, type-safe abstractions that adapt to your code's structure. Mastering these advanced features enables you to write APIs that are both flexible and precise.โž

In this deep dive, we'll explore three pillars of TypeScript's advanced type system: Conditional Types (which bring logic to the type level), Mapped Types (which transform properties), and the built-in Utility Types (like Partial, Pick, ReturnType). We'll also learn how to combine them to create custom, reusable type utilities that eliminate boilerplate and catch bugs at compile time.

1. Conditional Types: Type-Level Logic

Conditional types have the syntax T extends U ? X : Y. They allow you to choose a type based on a condition, similar to ternary operators in JavaScript. They are the foundation for many advanced patterns.

// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>;  // true
type B = IsString<number>;   // false

// Using with generics
type TypeName<T> =
  T extends string ? 'string' :
  T extends number ? 'number' :
  T extends boolean ? 'boolean' :
  T extends undefined ? 'undefined' :
  T extends Function ? 'function' :
  'object';

type T1 = TypeName<() => void>;   // 'function'
type T2 = TypeName<string[]>;     // 'object'

The infer keyword allows you to extract a type from within another type. It's especially useful in conditional types to unwrap nested structures.

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

// Extract element type from an array
type ElementType<T> = T extends (infer U)[] ? U : never;
type E = ElementType<string[]>;  // string
Distributive conditional types: When a conditional type acts on a union, the condition is applied to each member individually. This is powerful for filtering or mapping over unions.
type Exclude<T, U> = T extends U ? never : T;
type Numbers = 1 | 2 | 3 | 'a' | 'b';
type OnlyNumbers = Exclude<Numbers, string>;  // 1 | 2 | 3

2. Mapped Types: Transforming Object Shapes

Mapped types allow you to iterate over keys of an object type and produce a new type. The syntax is { [P in K]: T } where K is a union of keys.

// Make all properties optional (like Partial)
type MyPartial<T> = { [P in keyof T]?: T[P] };
interface User { name: string; age: number; }
type OptionalUser = MyPartial<User>;  // { name?: string; age?: number; }

// Make all properties readonly
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };

// Adding or removing modifiers with + or -
type Mutable<T> = { -readonly [P in keyof T]: T[P] };

Key remapping (TypeScript 4.1+) using the as clause lets you filter or rename keys.

// Filter out keys that start with '_'
type RemoveInternal<T> = {
  [P in keyof T as P extends `_${string}` ? never : P]: T[P];
};
type Data = { id: number; _secret: string; name: string };
type Clean = RemoveInternal<Data>;  // { id: number; name: string }

// Rename keys by adding a prefix
type AddPrefix<T, Prefix extends string> = {
  [P in keyof T as `${Prefix}${string & P}`]: T[P];
};
type Prefixed = AddPrefix<{ a: number; b: string }, 'prop_'>;
// { prop_a: number; prop_b: string }

Mapped types are the foundation for many utility types like Pick, Omit, and Record.

3. Built-in Utility Types: The Essentials

TypeScript provides a set of globally available utility types that handle common transformations. Here are the most useful ones:

๐Ÿ”ง Partial<T>

Makes all properties optional.

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

๐Ÿ”ง Required<T>

Makes all properties required (removes optionality).

๐Ÿ”ง Readonly<T>

Makes all properties readonly.

๐Ÿ”ง Pick<T, K>

Selects a subset of keys.

type NameOnly = Pick<User, 'name'>; // { name: string }

๐Ÿ”ง Omit<T, K>

Excludes keys.

type WithoutId = Omit<User, 'id'>; // { name: string }

๐Ÿ”ง Record<K, T>

Constructs an object type with keys K and values T.

type CatNames = 'mimi' | 'leo';
type Cats = Record<CatNames, { age: number }>;

๐Ÿ”ง ReturnType<T>

Extracts return type of a function.

๐Ÿ”ง Parameters<T>

Extracts parameters as a tuple.

// ReturnType and Parameters
function greet(name: string, age: number): string {
  return `${name} is ${age} years old`;
}
type GreetReturn = ReturnType<typeof greet>;  // string
type GreetParams = Parameters<typeof greet>; // [string, number]

4. More Advanced Utilities

Modern TypeScript adds more utilities that simplify common patterns:

// Awaited
type P = Promise<string>;
type A = Awaited<P>;  // string

// NonNullable
type MaybeNumber = number | null | undefined;
type DefiniteNumber = NonNullable<MaybeNumber>; // number

// InstanceType
class Animal { name: string; }
type AnimalInstance = InstanceType<typeof Animal>; // Animal

5. Combining Techniques: A Real-World Example

Let's build a type-safe Redux-like reducer helper that uses conditional types, mapped types, and utilities to create action handlers.

// Define action creators with payload types
type ActionMap<M extends { [index: string]: any }> = {
  [Key in keyof M]: M[Key] extends undefined
    ? { type: Key }
    : { type: Key; payload: M[Key] };
};

type UserActions = {
  setName: string;
  setAge: number;
  reset: undefined;
};

type Actions = ActionMap<UserActions>;
// { setName: { type: "setName"; payload: string };
//   setAge: { type: "setAge"; payload: number };
//   reset: { type: "reset" }; }

type UserState = { name: string; age: number };

// Create a type-safe reducer
type Reducer<S, A> = (state: S, action: A) => S;

type ReducerHandlers<S, AM extends ActionMap<any>> = {
  [K in keyof AM]: (state: S, action: AM[K]) => S;
};

function createReducer<S, AM extends ActionMap<any>>(
  initialState: S,
  handlers: ReducerHandlers<S, AM>
): Reducer<S, AM[keyof AM]> {
  return (state = initialState, action) => {
    const handler = handlers[action.type as keyof AM];
    return handler ? handler(state, action as any) : state;
  };
}

// Usage
const userReducer = createReducer<UserState, UserActions>(
  { name: '', age: 0 },
  {
    setName: (state, action) => ({ ...state, name: action.payload }),
    setAge: (state, action) => ({ ...state, age: action.payload }),
    reset: (state) => ({ name: '', age: 0 }),
  }
);

// userReducer is fully typed; dispatching an invalid action will error

This pattern leverages mapped types to generate action types, conditional types for payload inference, and utility types to keep the code DRY and type-safe.

6. Building Custom Utility Types

Combining conditional and mapped types lets you create domain-specific utilities. Here are a few useful examples:

// DeepPartial: recursively make all properties optional
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface Nested {
  a: number;
  b: { c: string; d: boolean };
}
type Deep = DeepPartial<Nested>; // all levels optional

// ValueOf: union of all property values
type ValueOf<T> = T[keyof T];
type Values = ValueOf<{ a: 1; b: 'hello' }>; // 1 | 'hello'

// FunctionArguments: extract argument types as a tuple (like Parameters)
type FunctionArguments<F> = F extends (...args: infer A) => any ? A : never;

// DeepReadonly: recursively mark all properties readonly
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

7. Advanced: Recursive Conditional Types

TypeScript allows recursive conditional types, enabling you to traverse nested structures. Here's a type that flattens a nested array (like Array.prototype.flat at the type level):

type Flatten<T> = T extends any[] ? (T[number] extends infer U ? Flatten<U> : never) : T;
type Nested = [1, [2, [3, [4]]]];
type Flat = Flatten<Nested>; // 1 | 2 | 3 | 4

// Another: type-level string concatenation (tail recursion optimization needed)
type Join<T extends string[], Sep extends string = ''> =
  T extends [infer First, ...infer Rest]
    ? First extends string
      ? Rest extends string[]
        ? `${First}${Rest extends [] ? '' : Sep}${Join<Rest, Sep>}`
        : never
      : never
    : '';
type Path = Join<['a', 'b', 'c'], '.'>; // "a.b.c"
Note: Recursive types have depth limits (~1000), but for most practical purposes they are sufficient.

8. Template Literal Types: String Manipulation at Type Level

Template literal types allow you to construct new string literal types by concatenating other string literals. They pair beautifully with conditional and mapped types.

type Greeting = `Hello, ${string}!`;  // matches any string starting with Hello, ending with !

type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>; // "onClick"

// Transform camelCase to kebab-case
type KebabCase<S extends string> = S extends `${infer Char}${infer Rest}`
  ? Char extends Lowercase<Char>
    ? `${Char}${KebabCase<Rest>}`
    : `-${Lowercase<Char>}${KebabCase<Rest>}`
  : S;
type Kebab = KebabCase<'getUserData'>; // "get-user-data"

Template literal types enable extremely expressive APIs, such as type-safe CSS-in-JS, routing, and event handling.

9. Best Practices and Common Pitfalls

โœ… Do's

  • Use infer to extract types, avoid hardcoding.
  • Leverage built-in utilities before building custom ones.
  • Keep conditional types simple; if they become too complex, consider splitting them.
  • Document advanced types for your team.

โš ๏ธ Don'ts

  • Avoid deeply nested conditional types; they can become unreadable and slow.
  • Don't use any when you can use unknown.
  • Be careful with circular references; TypeScript may bail out with any.
  • Test your complex types with // @ts-expect-error to ensure they behave as expected.

10. Performance and Type Instantiation

TypeScript's type checker can be slowed by complex recursive or conditional types. To keep your project fast:

Final Thoughts: Master the Type System

Conditional types, mapped types, and utility types form the cornerstone of advanced TypeScript programming. They allow you to create APIs that are not only expressive but also self-documenting and resilient to change. Whether you're building a library, a large-scale application, or a design system, investing time in these patterns pays off in fewer runtime errors and a more enjoyable developer experience.

Start by exploring the built-in utilities, then gradually incorporate conditional and mapped types to model domain constraints. As you become comfortable, you'll find yourself writing types that feel like a natural extension of your business logic.

Happy typing โ€” may your types be precise and your errors compile-time only.