11.2 Generic Functions: Syntax and Type Inference
Right, let’s talk about generic functions. You’ve probably felt the pain this solves: writing a function that works perfectly for a string, and then you need the exact same logic for a number. Your first instinct might be to reach for any and call it a day. Don’t. You’re better than that. any is a declaration of surrender to the type system; it’s you saying, “I give up, just let me compile.” Generics are how you win instead.
Think of a generic as a placeholder type, a little slot where you can drop in whatever type you need. You’re not writing a function for a specific type; you’re writing a blueprint for a function that can work with any type, as long as you use that type consistently.
The Basic Syntax: It’s a Contract
Here’s the simplest form. You declare your type variable in angle brackets after the function name. The convention is to use single uppercase letters like T (for Type), U, K (for Key), V (for Value). It’s not a law, but it’s what everyone does, so just do it.
function identity<T>(arg: T): T {
return arg;
}
// Usage
let outputString = identity<string>("myString"); // type of outputString is 'string'
let outputNumber = identity<number>(42); // type of outputNumber is 'number'
The beauty here is the contract: “You give me a thing of type T, I promise to return a thing of that exact same type T.” No funny business. The type flows through the function.
Let TypeScript Do the Work: Type Inference
Now, you’ll notice the previous example is a bit… verbose. Who wants to type identity<number>(42)? Thankfully, the TypeScript compiler isn’t stupid. It has a powerful type inference system. In most cases, you can just let it figure T out from the argument you provide.
let outputString = identity("myString"); // TS infers T = string
let outputNumber = identity(42); // TS infers T = number
This is the preferred way to use generics 99% of the time. You get all the safety without the extra typing. You only need to explicitly specify the type when the compiler can’t infer it from context, which is a rare edge case.
Working with Arrays and Constraints
Of course, you’ll rarely just return the argument. Let’s say you want to get the first element of an array. Your first attempt might look like this:
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
This works great. T represents the type of the items inside the array. So for getFirstElement([1, 2, 3]), T is inferred as number and the return type is number.
But what if you want to do something to the element? Something that requires it to have a specific property? This is where you hit a wall. The following will cause a compile error because, as far as the type system knows, T could be anything, including something without a .length.
function logLength<T>(arg: T): void {
console.log(arg.length); // Error: Property 'length' does not exist on type 'T'.
}
This is TypeScript protecting you from yourself. The solution is a constraint. You use the extends keyword to tell TypeScript, “No, T isn’t any type; it’s any type that has at least these properties.”
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): void {
console.log(arg.length); // Now it works!
}
logLength("hello"); // OK, strings have a length
logLength([1, 2, 3]); // OK, arrays have a length
logLength(42); // Error: number doesn't have a 'length' property
This is incredibly powerful. You’re not restricting the function to one type; you’re defining a minimum requirement that many types can fulfill. This is the core of designing flexible and reusable generic functions.
Using Multiple Type Variables
Functions can be generic over more than one type. Just declare multiple variables.
function mapArray<T, U>(arr: T[], mapFn: (item: T) => U): U[] {
return arr.map(mapFn);
}
const stringArray = ["1", "2", "3"];
const numberArray = mapArray(stringArray, (num) => parseInt(num)); // T is string, U is number
// numberArray is of type number[]
Here, T is the type of the input array’s elements, and U is the type of the output array’s elements. The function’s contract ensures the transformation is type-safe from end to end.
A Common Pitfall: Overconstraining
The biggest mistake I see is people getting constraint-happy. They start defining constraints like T extends SomeVerySpecificInterface when all they need is T extends { id: number }. Be minimal. Only require what the function logic actually needs. The more you constrain, the less useful and reusable your function becomes. Remember, you’re writing a blueprint, not a one-off. Design for the general case, and let the caller provide the specifics.