Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably run into a situation where a function needs to be flexible. Maybe it accepts a string and returns a number, but if it accepts a number, it returns a string. Or perhaps it takes a single ID and returns a user object, but if it takes a configuration object, it returns a newly created user. In these cases, your first instinct is likely to reach for union types. It feels like the "TypeScript way."

The Union Type Trap

Let's look at a utility function I wrote a while back for a data processing tool. I wanted a function called formatValue that could handle either a raw number (returning it as a formatted string) or a formatted string (returning it as a number for calculations). Here is how I originally wrote it:

function formatValue(value: string | number): string | number {
  if (typeof value === 'string') {
    return parseFloat(value);
  }
  return value.toFixed(2);
}

On the surface, this looks fine. It compiles. But here is where the experience falls apart for the person calling the function. If I call formatValue("10.5"), I know for a fact that I'm getting a number back. However, TypeScript doesn't know that. It looks at the return signature and tells me the result is string | number. Now I'm forced to use a type guard or a clumsy type assertion (the dreaded as number) every single time I use the function, just to satisfy the compiler.

This is the core problem with simple unions: they describe the inputs and outputs in isolation. They don't describe the relationship between a specific input and a specific output.

Tying Inputs to Outputs with Overloads

This is exactly why function overloads exist. Overloading allows you to tell TypeScript: "If the input is X, the output will definitely be Y. But if the input is A, the output will be B."

To do this, you write one or more "overload signatures" first, followed by the actual "implementation signature." The implementation signature is the one that actually contains the function body, and it's not visible to the caller—only the overloads are.

// Overload signatures
function formatValue(value: string): number;
function formatValue(value: number): string;

// Implementation signature
function formatValue(value: string | number): string | number {
  if (typeof value === 'string') {
    return parseFloat(value);
  }
  return value.toFixed(2);
}

Now, when you call formatValue("10.5"), TypeScript matches it against the first overload and knows the return type is number. No more casting, no more ambiguity. It's a much cleaner experience for whoever is using your API.

The Implementation Gap

There is one detail here that trips people up: the implementation signature must be compatible with all of the overloads. I've seen developers try to make the implementation signature too specific, but it has to be broad enough to cover every single overload you've defined. If you have an overload that accepts a boolean but your implementation only accepts string | number, TypeScript will throw an error.

I should also mention a common mistake: don't use overloads just to avoid writing a more generic type. If you find yourself writing five overloads that all do the same thing, you're probably better off using a Generic. Overloads are for when the return type changes based on the type of the argument, not just when the types are different.




📋 Practical Task

Refactoring the API Response Handler

You are working on a library that fetches data from an API. Currently, there is a function called fetchData that is causing headaches for the team because it uses a union return type, forcing everyone to use type assertions.

The Current Code:

interface User { id: string; name: string; }
interface Post { id: string; title: string; }

function fetchData(id: string, type: 'user' | 'post'): User | Post {
  if (type === 'user') {
    return { id, name: 'John Doe' };
  }
  return { id, title: 'Hello World' };
}

// This is causing the issue:
const user = fetchData('123', 'user'); 
// 'user' is inferred as User | Post, but we know it's a User!

Your Task:
Rewrite the fetchData function using function overloads so that when 'user' is passed as the type, the return type is strictly User, and when 'post' is passed, the return type is strictly Post. Ensure the implementation signature remains compatible with both overloads.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.