DNA🔷 TypeScriptGenerics & Conditional Types
ðŸĶ–DinosaurTypeScriptGenericsConditional Types

Generics & Conditional Types

The type-level programming toolkit. Generics, conditional types, infer, mapped types, and template literals — the constructs that let you build types as expressive as your runtime code.

Generics & Conditional Types

Generics are TypeScript's abstraction mechanism at the type level. Conditional types add branching logic. Together they let you write types that are computed, not just declared — and that's what separates a senior engineer's TypeScript from everyone else's.

Generic Functions — Type Inference and Constraints

A generic function defers a type decision to the call site:

function identity<T>(value: T): T {
  return value;
}
 
const a = identity("hello"); // T inferred as "hello" (literal type)
const b = identity(42);       // T inferred as 42

Constraints with extends

Constraints restrict what types a generic can accept — this is bounded polymorphism:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: "Ada", age: 36 };
getProperty(user, "name");  // ✅ returns string
getProperty(user, "email"); // ❌ Error: "email" is not in keyof typeof user

The constraint K extends keyof T means K must be one of the known keys of T. The return type T[K] is an indexed access type — it resolves to the type of the property at key K.

Generic Defaults

Generics can have defaults, just like function parameters:

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  timestamp: Date;
}
 
const response: ApiResponse = { data: {}, status: 200, timestamp: new Date() };
const typed: ApiResponse<User> = { data: user, status: 200, timestamp: new Date() };

Generic Interfaces and Classes

interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}
 
class InMemoryRepository<T extends { id: string }> implements Repository<T> {
  private store = new Map<string, T>();
 
  async findById(id: string) {
    return this.store.get(id) ?? null;
  }
  async findAll() {
    return [...this.store.values()];
  }
  async save(entity: T) {
    this.store.set(entity.id, entity);
    return entity;
  }
  async delete(id: string) {
    this.store.delete(id);
  }
}

The constraint T extends { id: string } ensures every entity has an id — the repository can safely use entity.id without further narrowing.

Conditional Types

Conditional types introduce branching at the type level:

type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;       // false

The infer Keyword

infer declares a type variable inside a conditional type — it extracts a type from a pattern:

type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
 
type A = ReturnOf<() => string>;        // string
type B = ReturnOf<(x: number) => void>; // void
 
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type C = UnpackPromise<Promise<string>>; // string
type D = UnpackPromise<number>;          // number (passthrough)

infer is how TypeScript's built-in ReturnType, Parameters, and Awaited are implemented.

Nested infer

You can nest infer to extract deeply:

type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
 
type Arg = FirstArg<(name: string, age: number) => void>; // string
 
type UnpackArray<T> = T extends Array<infer U>
  ? U extends Promise<infer V>
    ? V
    : U
  : T;
 
type E = UnpackArray<Promise<string>[]>; // string

Distributive Conditional Types

When a conditional type acts on a naked type parameter that receives a union, it distributes — applying the condition to each member individually:

type ToArray<T> = T extends any ? T[] : never;
 
type Result = ToArray<string | number>;
// Distributes: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]

Preventing Distribution

Wrap both sides in a tuple to prevent distribution:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
 
type Result2 = ToArrayNonDist<string | number>;
// = (string | number)[]  — no distribution

This is critical for utility types that should treat a union as a single unit rather than spreading over it.

Mapped Types

Mapped types transform every property in a type:

type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };

Key Remapping with as

TypeScript 4.1 added key remapping — transform the keys themselves:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
}
 
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }

Filtering Keys

Use as with never to filter out keys:

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringFields = OnlyStrings<{ name: string; age: number; email: string }>;
// { name: string; email: string }

Template Literal Types

String manipulation at the type level:

type EventName = `on${Capitalize<"click" | "scroll" | "resize">}`;
// "onClick" | "onScroll" | "onResize"
 
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = "/users" | "/posts";
type APIRoute = `${HTTPMethod} ${Endpoint}`;
// "GET /users" | "GET /posts" | "POST /users" | ... (8 total combinations)

Template literals distribute over unions, creating every combination — powerful for expressing string-based APIs at the type level.

Recursive Conditional Types

Types can reference themselves — enabling operations on arbitrary-depth structures:

type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;
 
type Nested = {
  a: { b: { c: string } };
  d: number[];
};
 
type Frozen = DeepReadonly<Nested>;
// { readonly a: { readonly b: { readonly c: string } }; readonly d: readonly number[] }

Type-Level Arithmetic (Advanced)

You can even do arithmetic with tuple lengths:

type BuildTuple<N extends number, T extends unknown[] = []> =
  T["length"] extends N ? T : BuildTuple<N, [...T, unknown]>;
 
type Add<A extends number, B extends number> =
  [...BuildTuple<A>, ...BuildTuple<B>]["length"];
 
type Sum = Add<3, 4>; // 7

This hits recursion limits quickly, but demonstrates the Turing-complete nature of TypeScript's type system.

Real-World Example: Type-Safe Event Emitter

type EventMap = {
  userLogin: { userId: string; timestamp: number };
  pageView: { path: string; referrer: string | null };
  error: { code: number; message: string };
};
 
class TypedEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<string, Set<Function>>();
 
  on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
    if (!this.listeners.has(event as string)) {
      this.listeners.set(event as string, new Set());
    }
    this.listeners.get(event as string)!.add(handler);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners.get(event as string)?.forEach(fn => fn(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
emitter.on("userLogin", (data) => {
  console.log(data.userId);    // ✅ autocomplete works
  console.log(data.path);      // ❌ Error: 'path' doesn't exist on userLogin
});
emitter.emit("error", { code: 500, message: "fail" }); // ✅
emitter.emit("error", { code: "500" });                 // ❌ code must be number

Real-World Example: Type-Safe API Client

interface APIEndpoints {
  "GET /users": { response: User[]; query: { limit?: number } };
  "GET /users/:id": { response: User; params: { id: string } };
  "POST /users": { response: User; body: CreateUserDTO };
  "PUT /users/:id": { response: User; params: { id: string }; body: UpdateUserDTO };
}
 
type ExtractMethod<T extends string> = T extends `${infer M} ${string}` ? M : never;
type ExtractPath<T extends string> = T extends `${string} ${infer P}` ? P : never;
 
type EndpointConfig<T extends keyof APIEndpoints> = APIEndpoints[T];
 
async function apiCall<E extends keyof APIEndpoints>(
  endpoint: E,
  config: Omit<EndpointConfig<E>, "response">
): Promise<EndpointConfig<E>["response"]> {
  // Implementation would parse method, path, and config
  throw new Error("Not implemented");
}
 
// Usage — fully typed request and response
const users = await apiCall("GET /users", { query: { limit: 10 } });
//    ^? User[]
const user = await apiCall("POST /users", { body: { name: "Ada", email: "ada@test.com" } });
//    ^? User

Real-World Example: Builder Pattern with Type Narrowing

interface QueryBuilder<
  HasSelect extends boolean = false,
  HasFrom extends boolean = false,
  HasWhere extends boolean = false,
> {
  select(fields: string[]): QueryBuilder<true, HasFrom, HasWhere>;
  from(table: string): QueryBuilder<HasSelect, true, HasWhere>;
  where(condition: string): QueryBuilder<HasSelect, HasFrom, true>;
  execute: HasSelect extends true
    ? HasFrom extends true
      ? () => Promise<unknown[]>
      : never
    : never;
}
 
function createQuery(): QueryBuilder {
  // implementation
  return {} as QueryBuilder;
}
 
const query = createQuery()
  .select(["name", "email"])
  .from("users")
  .where("active = true");
 
query.execute(); // ✅ — only available after both select() and from()
 
const incomplete = createQuery().select(["name"]);
incomplete.execute; // ❌ — type is `never`, cannot call

The builder tracks which methods have been called through type parameters, making invalid states unrepresentable.

Interview Power Moves

  1. "Generics are parametric polymorphism — they let you write code that's generic over types, not just values" — shows you understand the PL theory behind the feature.

  2. "Distributive conditional types spread over unions by default — wrap in a tuple to prevent it" — this catches many experienced engineers off guard.

  3. "I use infer to extract types from existing structures rather than declaring parallel type hierarchies" — shows you avoid duplication at the type level.

  4. "Mapped types with key remapping replaced 90% of the cases where we used to need code generation" — practical insight about real codebases.

  5. "TypeScript's type system is Turing-complete, which means you can express almost anything — but you shouldn't. I optimize for readability at the type level just like at the value level."