TypeScript’s function overloads and generics are both powerful tools for creating flexible and type-safe functions, but they solve fundamentally different problems and are used in distinct scenarios.

Let’s see function overloads in action. Imagine you’re building a library for string manipulation. You want a function, formatString, that can take a string and either format it with padding or truncate it.

function formatString(input: string, options: { pad: number }): string;
function formatString(input: string, options: { truncate: number }): string;
function formatString(input: string, options: { pad: number; truncate: number }): string;
function formatString(input: string, options: { pad?: number; truncate?: number }): string {
    if (options.pad !== undefined) {
        // Logic for padding
        return input.padEnd(options.pad, ' ');
    } else if (options.truncate !== undefined) {
        // Logic for truncation
        return input.substring(0, options.truncate);
    }
    return input; // Default if no options
}

const padded = formatString("hello", { pad: 10 }); // string
const truncated = formatString("hello world", { truncate: 5 }); // string
// const invalid = formatString("hello", { unknownOption: true }); // Error: Argument of type '{ unknownOption: boolean; }' is not assignable to parameter of type '{ pad: number; } | { truncate: number; } | { pad: number; truncate: number; }'.

Here, formatString has three distinct signatures defined before the actual implementation. TypeScript uses these overload signatures to enforce that when you call formatString, you must provide arguments that match one of these specific patterns. The implementation signature, (input: string, options: { pad?: number; truncate?: number }): string, is only used internally by TypeScript for type checking the implementation itself, not for external calls. This is crucial: the overload signatures are the contract the outside world sees.

Now, let’s look at generics. Generics allow you to write functions that operate on a variety of types while preserving type information. Consider a function that takes an array and returns the first element, but you want it to work for any array type and return the correct element type.

function getFirstElement<T>(arr: T[]): T | undefined {
    return arr.length > 0 ? arr[0] : undefined;
}

const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // Type of firstNumber is number

const strings = ["a", "b", "c"];
const firstString = getFirstElement(strings); // Type of firstString is string

// const mixed = [1, "a", true];
// const firstMixed = getFirstElement(mixed); // Type of firstMixed is string | number | boolean

In getFirstElement<T>, T is a type parameter. When you call getFirstElement(numbers), TypeScript infers T to be number because numbers is an array of numbers. The function then correctly returns a number (or undefined). This is powerful because the function’s logic (returning the first element) is generic, and the type T is specific to the context of the call. You’re not defining multiple distinct behaviors; you’re defining one behavior that adapts to the type.

The core problem overloads solve is handling distinct argument patterns for a single function name. They are about defining a set of specific, known combinations of input types that lead to specific output types. You use overloads when the shape of the input dictates a different outcome or requires a different set of parameters. Think of them like overloaded methods in other languages: same name, different signatures, different execution paths.

Generics, on the other hand, solve the problem of writing functions that can work with any type while maintaining type safety. They are about abstracting over types. You use generics when the logic of the function is independent of the specific types involved, and you want the function to be reusable across many types without losing type information. This is essential for data structures, utility functions, and any scenario where a common operation needs to be applied to diverse data.

A key characteristic of overloads is that the implementation signature is not directly callable by users. If you try to call formatString with options: { pad?: number; truncate?: number } directly, TypeScript will complain because that specific signature isn’t one of the declared overloads. The implementation signature is a union of all possibilities for the internal logic, but the external contract is defined by the explicit overload signatures.

Here’s the counterintuitive part: a single generic function can often replace multiple overload signatures, but not always. If your overloads represent fundamentally different behaviors triggered by distinct parameter shapes, generics might not be the right fit. However, if your overloads are primarily about handling different types of the same conceptual input, generics can be more concise. For instance, if you had overloads for process<string>(data: string) and process<number>(data: number), a generic process<T>(data: T) would be a cleaner solution. The decision hinges on whether the difference is in the type of data or the structure/meaning of the parameters.

The next step in mastering TypeScript’s type system is understanding conditional types, which build upon generics to create even more dynamic and context-aware types.

Want structured learning?

Take the full Typescript course →