Generics Deep Dive

TypeScript Intermediate
Course 2 ยท Chapter 2 ยท Generics Deep Dive

๐Ÿ”ถ Generics Deep Dive

Generics are TypeScript's superpower for writing reusable, type-safe code. You've seen basic generics (`Array`, `Promise`), but true mastery comes from understanding constraints, variance, and how to compose generic types. This chapter teaches you to think in generics.

Generic Constraints: Restricting Type Parameters

By default, a type parameter T can be anything. Constraints let you say "T must satisfy this requirement":

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];  // โœ… TypeScript knows key exists on T
}

const user = { name: "Alice", age: 30 };
const result = getProperty(user, "name");  // โœ… "name" is valid
// getProperty(user, "email");  // โŒ "email" is not a key of user

Key syntax: K extends keyof T means "K must be a key that exists on T."

Extends a Type

T extends string โ€” T must be a string or a subtype of string.

Extends a Union

T extends string | number โ€” T must be one of these types.

Extends keyof

K extends keyof T โ€” K must be a property name of T.

Multiple Constraints

Chain them: <T extends { length: number }> โ€” T must have a length property.

Example: Pick from an Array with Constraints

// Only works on objects with a specific property type
function pickByType<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

interface Config {
  port: number;
  host: string;
}

const config: Config = { port: 3000, host: "localhost" };
const port = pickByType(config, "port");  // โœ… type is number

// Type inference knows the exact return type based on which key you pick!

Default Type Parameters

Just like function parameters, type parameters can have defaults:

type Result<T = string> = {
  success: boolean;
  data: T;
};

type StringResult = Result;         // T defaults to string
type NumberResult = Result<number>; // T is explicitly number

const response: StringResult = {
  success: true,
  data: "hello"  // No need to specify <string>
};

Variance: Covariance & Contravariance

Variance describes how generic types relate when their type parameters are subtypes of each other. This is subtle but crucial:

class Animal {}
class Dog extends Animal {}

// Arrays are COVARIANT in their type parameter
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;  // โœ… Dog[] is assignable to Animal[]

// Functions are CONTRAVARIANT in their parameter type
const feedAnimal = (a: Animal) => { };
const feedDog = (d: Dog) => { };

const callback: (animal: Animal) => void = feedDog;  // โŒ Error!
// A function expecting a Dog can't be used where we pass any Animal

Covariance

If Dog extends Animal, then Box<Dog> extends Box<Animal>. Read-only types.

Contravariance

If Dog extends Animal, then Callback<Animal> extends Callback<Dog>. Function parameters.

Invariance

Box<Dog> is not assignable to Box<Animal>. Mutable types.

Why It Matters

Prevents type errors. Contravariance protects function parameter safety.

keyof and typeof: Type Introspection

keyof gets property names; typeof gets the type of a value:

const user = { name: "Alice", age: 30 };

// typeof gets the type of the value
type UserType = typeof user;  // { name: string; age: number }

// keyof gets all property names
type UserKeys = keyof UserType;  // "name" | "age"

// Combine them: create a type that maps keys to their values
type UserValues = UserType[keyof UserType];  // string | number

// Practical: generic object property getter
function createGetter<T, K extends keyof T>(obj: T, key: K) {
  return () => obj[key];
}

Generic Utility Types: The Toolkit

TypeScript provides built-in generic utilities for common transformations:

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

// Partial: make all properties optional
type PartialUser = Partial<User>;

// Pick: select specific properties
type UserPreview = Pick<User, "name" | "email">;

// Omit: exclude specific properties
type UserWithoutId = Omit<User, "id">;

// Record: map keys to a value type
type UserRoles = Record<"admin" | "user" | "guest", User[]>;

// Readonly: make all properties readonly
type ReadOnlyUser = Readonly<User>;

// Extract: types assignable to U from union T
type StringOrNumber = string | number | boolean;
type Strings = Extract<StringOrNumber, string>;  // string

// Exclude: opposite of Extract
type NonStrings = Exclude<StringOrNumber, string>;  // number | boolean

Partial / Required

Toggle optionality. Partial<T> makes all optional; Required<T> makes all required.

Pick / Omit

Slice a type. Pick selects properties; Omit excludes them.

Record

Build a type from keys. Record<K, V> creates an object with keys K, values V.

Extract / Exclude

Filter unions. Extract keeps matching types; Exclude removes them.

Building Your Own Generic Utilities

Chain utilities and constraints to build powerful reusable patterns:

Example: Extracting Function Parameters

// Get all parameter types from a function
type Parameters<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;

const add = (a: number, b: number) => a + b;

type AddParams = Parameters<typeof add>;  // [number, number]

// Now you can build around it
function callWithLogging<T extends (...args: any[]) => any>(
  fn: T,
  ...args: Parameters<T>
) {
  console.log("Calling with", args);
  return fn(...args);
}

Recursive Generics: Types That Call Themselves

Generics can reference themselves for deeply nested structures:

// Make a type recursive: useful for nested data
type NestedArray<T> = T | NestedArray<T>[];

const deep: NestedArray<number> = [
  1,
  [2, 3],
  [4, [5, 6]]  // โœ… Arbitrary nesting is valid
];

// Practical: make object values deeply partial
type DeepPartial<T> = T extends object ? {
  [K in keyof T]?: DeepPartial<T[K]>
} : T;

interface Config {
  db: {
    host: string;
    port: number;
    ssl: {
      cert: string;
    }
  }
}

type PartialConfig = DeepPartial<Config>;  // All properties at all levels optional

๐Ÿ—๏ธ Real-World Pattern: Generic API Response Handler

Type-Safe Fetch Wrapper

interface ApiResponse<T> {
  status: "success" | "error";
  data?: T;
  error?: string;
}

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  return response.json();
}

async function getUser(id: number) {
  const user = await fetchJson<{ id: number; name: string }>(
    `/api/users/${id}`
  );
  console.log(user.name);  // โœ… TypeScript knows name exists
}

๐Ÿ’ป Coding Challenges

Challenge 1: Generic with Constraints

Write a function that takes an object and a key, and returns the value. Use constraints to ensure the key exists on the object. Bonus: make the return type exactly match the property type.

Goal: Practice keyof constraints and type inference.

โ†’ Solution

Challenge 2: Build a Utility Type

Create a utility type GetType<T, K> that takes an object type and a key, and returns the type of that property. Test it on a sample interface.

Goal: Combine keyof, conditional types, and generics.

โ†’ Solution

Challenge 3: Recursive Generic

Create a type Flatten<T> that "unwraps" nested arrays. For example, Flatten<[1, [2, 3]]> should be 1 | 2 | 3.

Goal: Build recursive type logic with array handling.

โ†’ Solution

โš ๏ธ Gotcha: Over-Constraining Generics

Constraints are powerful but can make code rigid. Before constraining a type parameter, ask: "Does this really need to be restricted?" Sometimes accepting any (though not ideal) is simpler than a complex constraint. Use constraints to enforce safety, not just to feel thorough.

๐ŸŽฏ What's Next

With generics mastered, we'll explore Decorators & Metadata โ€” a powerful (and experimental) feature for attaching type information and behavior to classes and properties.