Type Challenges

Difficulty: Advanced

Type challenges are puzzles that test your ability to build complex types using TypeScript's type system. They are a staple of senior-level TypeScript interviews because they demonstrate deep understanding of conditional types, mapped types, template literal types, and the `infer` keyword. Mastering these patterns enables you to write utility types that enforce complex invariants at compile time.

Conditional types (`T extends U ? X : Y`) are the if-else of the type system. Combined with the `infer` keyword, they can extract types from complex structures. For example, `type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never` extracts the return type of a function. The `infer` keyword declares a type variable that TypeScript fills in by matching the pattern - it is the cornerstone of type-level programming.

Mapped types iterate over the keys of a type and transform each property. The syntax `{ [K in keyof T]: NewType }` creates a new type by applying a transformation to each property. You can add or remove modifiers like `readonly` and `?` using `+` and `-`. Combined with conditional types, mapped types enable powerful transformations like making all properties nullable, extracting only methods, or deeply making an object readonly.

Recursive types allow types to reference themselves, enabling you to handle nested structures of arbitrary depth. A `DeepReadonly<T>` type, for example, makes an object and all its nested properties readonly. A `Flatten<T>` type can recursively unwrap nested arrays. TypeScript has a recursion depth limit (around 1000 levels), but this is sufficient for all practical use cases. These recursive patterns are among the most impressive demonstrations of TypeScript's type system power.

Code examples

Implementing Built-in Utility Types

// Implementing Pick, Omit, and Partial from scratch

// Pick: select specific keys from a type
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// Omit: remove specific keys (using Exclude)
type MyExclude<T, U> = T extends U ? never : T;
type MyOmit<T, K extends keyof T> = MyPick<T, MyExclude<keyof T, K>>;

// Partial: make all properties optional
type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

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

// Test the utility types at runtime with objects
const picked: MyPick<User, 'name' | 'email'> = { name: 'Alice', email: 'alice@test.com' };
console.log(`Picked: ${JSON.stringify(picked)}`);

const omitted: MyOmit<User, 'id' | 'age'> = { name: 'Bob', email: 'bob@test.com' };
console.log(`Omitted: ${JSON.stringify(omitted)}`);

const partial: MyPartial<User> = { name: 'Charlie' };
console.log(`Partial: ${JSON.stringify(partial)}`);

Pick selects keys using a mapped type over K. Omit uses Exclude to remove keys from keyof T, then picks the remaining ones. Partial adds '?' to every property.

Conditional Types with Infer

// Using infer to extract types from complex structures

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

// Extract element type from an array
type ElementType<T> = T extends (infer E)[] ? E : never;

// Extract Promise resolved type
type Awaited<T> = T extends Promise<infer U> ? (U extends Promise<any> ? Awaited<U> : U) : T;

// Demonstrate with runtime values
function getUser(): { name: string; age: number } {
  return { name: 'Alice', age: 30 };
}

const user: MyReturnType<typeof getUser> = { name: 'Alice', age: 30 };
console.log(`ReturnType: ${JSON.stringify(user)}`);

const numbers: number[] = [1, 2, 3];
const element: ElementType<typeof numbers> = numbers[0];
console.log(`ElementType: ${element}`);

// Simulating Awaited
const resolved: string = 'hello'; // Awaited<Promise<string>> = string
console.log(`Awaited: ${resolved}`);

The 'infer' keyword declares a type variable that TypeScript fills in by pattern matching. ReturnType infers R from the function's return position. ElementType infers E from the array's element position.

Deep Recursive Types

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

// Runtime simulation of deep freeze
function deepFreeze<T extends Record<string, any>>(obj: T): T {
  const result: any = {};
  for (const key of Object.keys(obj)) {
    const value = obj[key];
    if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
      result[key] = deepFreeze(value);
    } else {
      result[key] = value;
    }
  }
  return Object.freeze(result) as T;
}

const config = deepFreeze({
  server: {
    host: 'localhost',
    port: 3000,
    ssl: {
      enabled: true,
      cert: '/path/to/cert'
    }
  },
  database: {
    url: 'postgres://localhost:5432/db'
  }
});

console.log(`Host: ${config.server.host}`);
console.log(`Port: ${config.server.port}`);
console.log(`SSL: ${config.server.ssl.enabled}`);
console.log(`DB: ${config.database.url}`);
console.log(`Is frozen: ${Object.isFrozen(config)}`);

DeepReadonly recursively applies readonly to nested objects. The runtime equivalent (deepFreeze) uses Object.freeze recursively. Both ensure immutability at their respective levels.

Key points

Concepts covered

type challenges, conditional types, mapped types, template literal types, recursive types, infer keyword