Look, you’ve written this function. It’s a good function. It takes a string and returns a string. You’re proud of it. Then your PM walks over and says, “Hey, that’s great. Now can it also work with numbers?” Your heart sinks. You don’t want to write concatNumbers and concatStrings. You’re not an animal. You could use any, but then you’d just be throwing your type safety out the window and inviting bugs to a free-for-all in your codebase. That’s where generics come in—they’re your way of telling TypeScript, “I don’t know what type this will be yet, but whatever it is, it’s going to be consistent, dammit.”

Think of a generic as a type variable. It’s a placeholder, a stand-in for a real type that will be provided later. You’re writing a blueprint for a function, interface, or class, and the generic is the part you leave blank, to be filled in when the blueprint is actually used.

The any Escape Hatch (And Why It’s a Trap)

Let’s be honest, we’ve all done this. The deadline is looming, and any is just sitting there, looking all easy and convenient.

function combine(a: any, b: any): any {
  return a + b;
}

const result1 = combine("Hello, ", "world"); // Type is `any`, not string!
const result2 = combine(5, 10); // Type is `any`, not number!

See the problem? We’ve lost all the information. The function’s contract is basically “garbage in, garbage out, and I won’t tell you what kind of garbage it’ll be.” The return type is any, so TypeScript can’t help you anymore. If you try to call result1.toUpperCase(), it’ll compile just fine, and then blow up at runtime if result1 was actually a number. This is the opposite of helpful.

Your First Generic Function: identity

Let’s start with the simplest generic function imaginable, one that just returns whatever you give it. It’s useless until you need it for a theoretical point, which is right now.

function identity<T>(arg: T): T {
  return arg;
}

// Now, watch the magic:
let outputString = identity<string>("myString"); // Type of outputString is string.
let outputNumber = identity<number>(42); // Type of outputNumber is number.

Here’s the breakdown: we’ve defined a type variable T (you can call it whatever you want, T is just a convention) in angle brackets right after the function name. This tells TypeScript, “Hey, I’m about to use a type called T in here.” We then use T to type the parameter (arg: T) and the return type (: T).

The beauty is in the call. When you call identity<string>("myString"), you’re explicitly setting T to string. So the function signature becomes (arg: string): string. But here’s the best part: TypeScript is brilliantly clever and can almost always infer the type.

let outputString = identity("myString"); // TS infers T = string. Type is string.
let outputNumber = identity(42); // TS infers T = number. Type is number.

You rarely have to explicitly state the type; it just figures it out from the argument you pass in. This is the power—you write the function once, and it automatically adapts to the types it’s given, all while maintaining strict type checking.

Constraining Your Generics with extends

Sometimes, “anything” is too broad. What if your generic function needs to know that the type will have a certain property? You can’t just assume every type has a .length, for example. This is where constraints come in, using the extends keyword. You’re telling TypeScript, “T can be any type, but it must satisfy this minimum requirement.”

Let’s write a function that gets the length of something, but only things that have a length.

function getLength<T extends { length: number }>(thing: T): number {
  return thing.length; // Now TS knows for sure that `thing` has a .length
}

getLength("hello"); // Works, strings have length
getLength([1, 2, 3]); // Works, arrays have length
getLength(42); // Error! Type 'number' has no property 'length'

Perfect. We’ve constrained T to only types that have a length: number property. The function is now safely reusable for any object with a length, and it will loudly complain if you try to pass it something that doesn’t fit that contract. This is how you build robust, flexible code without resorting to any.

The Real-World Example: A merge Function

Let’s build something you’d actually use. A function that merges two objects.

function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const merged = merge(
  { name: "Alice", hobbies: ["coding"] },
  { age: 30 }
);
// merged has type: { name: string; hobbies: string[]; } & { age: number; }
// Which is effectively: { name: string; hobbies: string[]; age: number; }

We use two generic types, T and U, for the two objects. The return type is an intersection T & U, meaning an object that has all the properties of both. This is infinitely more useful and type-safe than writing a separate function for every possible object combination you might ever need to merge. This is the “reusable typed code” the section title promised. You’ve written one function that can handle a near-infinite number of type scenarios, correctly. That’s not just convenient; it’s professional-grade.