Right, so you’ve hit the point where you need a function to handle a few different flavors of input. Your first, perfectly reasonable instinct might be to reach for a union type. Let’s say you want a function that can format either a Date object or a UNIX timestamp (which is a number).

function formatTimestamp(input: Date | number): string {
  if (input instanceof Date) {
    return input.toISOString();
  } else {
    return new Date(input).toISOString();
  }
}

This works. It’s clear, and TypeScript is happy because we’ve narrowed the type inside the function. But what if the output type also changes based on the input? What if you want to return a string for a Date input, but a number (maybe the raw timestamp) for a number input? Your union approach just blew up in your face. The return type would have to be string | number, which is probably a nightmare for whoever calls your function.

This is where function overloads come in. They let you define the exact relationship between specific input types and specific output types, providing a much more precise interface for your consumers.

The Overload Signature Shuffle

Think of overloads as you showing off. You’re listing all the cool, specific ways your function can be called, and you’re doing it upfront. Here’s how we’d solve our problem:

// Overload signatures (the showboating)
function formatTimestamp(input: Date): string;
function formatTimestamp(input: number): number;

// Implementation signature (the actual work)
function formatTimestamp(input: Date | number): string | number {
  if (input instanceof Date) {
    return input.toISOString(); // string
  } else {
    return input; // number
  }
}

// Usage is now perfectly precise
const s: string = formatTimestamp(new Date());
const n: number = formatTimestamp(1625097600000);

See the magic? The caller only sees the overload signatures. They get beautiful, narrow types. The implementation signature, which the caller never sees, is the messy reality we have to deal with. It’s the wizard behind the curtain, frantically making the magic happen.

Why You Might Still Prefer a Union (And Why You’d Be Wrong Sometimes)

Unions are simpler to write and read when the function’s internal logic is essentially the same for all types. If you’re just doing type narrowing inside one function body to handle the different cases, a union parameter is often the right call. It’s less code, less cognitive overhead.

Overloads are non-negotiable when:

  1. The return type depends on the input type. This is the big one, as shown above.
  2. The number or structure of parameters changes. For example, a function that can take either (x: number, y: number) or a single (point: {x: number, y: number}) object. A union can’t express that.
// Without overloads, this is impossible to type cleanly.
function createPoint(x: number, y: number): Point;
function createPoint(coords: { x: number; y: number }): Point;
function createPoint(arg1: number | { x: number; y: number }, arg2?: number): Point {
  if (typeof arg1 === 'number') {
    return { x: arg1, y: arg2! }; // Note the non-null assertion we need for arg2
  } else {
    return { x: arg1.x, y: arg1.y };
  }
}

The Pitfalls: Where the Shine Wears Off

Overloads are powerful, but they have a dark side. The implementation signature is a lie you have to maintain. It has to be compatible with all your overloads, but it’s notoriously loose. Notice how in the last example I had to use a non-null assertion (arg2!). This is because the implementation signature has to be a superset of all the overloads, so from its perspective, arg2 is optional. This can make the implementation… prickly.

The other major headache is ordering. TypeScript resolves overloads in the order they are written. It picks the first matching overload. So if you write a very general overload first, it will greedily match everything and your more specific ones will never be seen. Always list your most specific signatures first.

// WRONG ORDER - The first one will catch EVERY string
function parseInput(input: string): string[];
function parseInput(input: string, delimiter: ','): number[];
function parseInput(input: string, delimiter?: ','): string[] | number[] {
  // ... implementation
}

// RIGHT ORDER - Specific first!
function parseInput(input: string, delimiter: ','): number[];
function parseInput(input: string): string[];
function parseInput(input: string, delimiter?: ','): string[] | number[] {
  // ... implementation
}

So, the rule of thumb: Use a union parameter for simple narrowing. Break out the overloads when the relationship between input and output gets complex or the function’s shape itself needs to change. Just be prepared to wrangle the implementation a bit more. It’s the price of providing a pristine interface.