Right, so you’ve mastered the single-type-parameter function. You feel pretty good about identity<T>(thing: T): T. You can pass a string, get a string. Pass a number, get a number. It’s elegant. It’s simple. It’s also, frankly, a bit boring. The real world is a messy place full of relationships, and our types need to reflect that. This is where we graduate from solitaire to playing with multiple cards at once.

We’re not just dealing with a single T anymore. We’re dealing with a mapping from one type to another, or the relationship between two independent types. Think of a function that takes a key and returns a value from a map. You don’t just have one type; you have the type of the key K and the type of the value V. The function’s signature needs to know about both.

The Basic Syntax: It’s Just More Letters

The syntax is exactly what you’d hope for: you just add more parameters, separated by commas. It looks like this:

function makePair<KeyType, ValueType>(key: KeyType, value: ValueType): [KeyType, ValueType] {
  return [key, value];
}

// Usage
const numberStringPair = makePair(10, "Ten"); // Type: [number, string]
const booleanDatePair = makePair(true, new Date()); // Type: [boolean, Date]

See? No magic. We declared two type parameters, KeyType and ValueType. The function then uses them to define the types of its two arguments and its return type (a tuple). The compiler infers both types independently from the arguments you provide. KeyType doesn’t care what ValueType is doing, and vice versa. They’re roommates, not a couple.

When Types Need to Relate: Constraining the Chaos

The example above is fine, but it’s not very constrained. What if you’re writing a function to get the value of a property from an object? There’s a relationship between the object type Obj and the key type Key: the key must be a key of the object. Letting Key be any random type, like a boolean, would be absurd and would cause a runtime error. We need to enforce this relationship. This is where the extends keyword earns its keep.

function getProp<Obj, Key extends keyof Obj>(object: Obj, key: Key): Obj[Key] {
  return object[key];
}

const myCar = { make: "Toyota", year: 2020, electric: false };

const make = getProp(myCar, "make"); // Type: string
const year = getProp(myCar, "year"); // Type: number

// This will brilliantly fail at compile time, which is exactly what we want.
// Error: Argument of type '"wheels"' is not assignable to parameter of type '"make" | "year" | "electric"'.
const wheels = getProp(myCar, "wheels");

Let’s break down the genius here. Key extends keyof Obj is the crucial constraint. It tells TypeScript: “Hey, for this function call, Key isn’t just any type. It must be a subtype of keyof Obj” (i.e., a string literal that is actually a key of the Obj we’re dealing with). The return type Obj[Key] is then a indexed access type, meaning “the type of the property Key on Obj.” This is how you create precise, type-safe functions that would be utter nightmares in vanilla JavaScript.

The Classic Map Function: A Relationship in Action

You’ve used Array.prototype.map. Let’s write our own version to see how these relationships work in a classic context. The mapping function fn takes an item of the array’s type T and transforms it into something else, type U. The relationship is between the input type T and the output type U.

function map<T, U>(array: T[], fn: (item: T) => U): U[] {
  const result: U[] = [];
  for (const item of array) {
    result.push(fn(item));
  }
  return result;
}

const stringNumbers = ["1", "2", "3"];
const actualNumbers = map(stringNumbers, (str) => parseInt(str)); // T is string, U is number
// actualNumbers is typed as number[]

Here, T and U are independent in their declaration but become related through the function fn we provide. The type checker ensures that the function you pass is compatible with the (item: T) => U signature, creating a safe chain of transformations.

A Common Pitfall: Over-Constraining with extends

A mistake I see all the time is using extends when you don’t actually need a relationship. Remember our simple makePair function? There’s absolutely no reason to do this:

// 🚫 Don't do this unless you actually need this constraint!
function makePairConstrained<KeyType extends string, ValueType extends number>(key: KeyType, value: ValueType) {
  return [key, value];
}

makePairConstrained("id", 100); // Works
makePairConstrained("name", "Alice"); // Error: Type 'string' is not assignable to type 'number'.

You’ve now wildly limited the utility of your function for no good reason. Only use extends when you want to enforce a specific relationship between the types. If they are truly independent, let them be free-range, organic type parameters.

The power of multiple type parameters is that they let you describe the complex, interconnected data flows in your program with precision. It’s the difference between saying “this function returns a thing” and “this function takes this specific kind of key and returns the corresponding kind of value from that specific kind of object.” That specificity is what turns runtime errors into compile-time conversations, which is the whole point of being here.