Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 052 / 100

Function overloads vs unions in JavaScript & TypeScript

Two ways to type flexible functions.

Expona Admin9 min read

Function overloads vs unions

When you're building production systems, you'll encounter functions that need to accept different argument types and return different results based on what you pass in. TypeScript gives you two main tools to handle this: function overloads and union types. Knowing when to reach for each one is the difference between writing flexible, maintainable code and ending up with a type system that fights you.

In this post, I'll walk you through both approaches with concrete examples from real platforms I've built, show you when to prefer one over the other, and highlight the common pitfalls that catch developers off guard.

Overloads — Different signatures with different return types

Function overloads let you declare multiple signatures for the same function. Each overload specifies a different combination of parameter types and return types. The implementation comes last and must satisfy all overload signatures.

Here's a practical example from a trading platform I worked on:

typescript
function formatPrice(value: number, currency: 'USD'): string;
function formatPrice(value: number, currency: 'BTC'): string;
function formatPrice(value: number, currency: string): string {
  if (currency === 'USD') {
    return `$${value.toFixed(2)}`;
  }
  if (currency === 'BTC') {
    return `₿${value.toFixed(8)}`;
  }
  return `${currency} ${value}`;
}

With overloads, the caller gets precise type information:

typescript
const usdPrice = formatPrice(100, 'USD');  // TypeScript knows this is string
const btcPrice = formatPrice(0.5, 'BTC');  // TypeScript knows this is string

This works well, but notice that both overloads return the same type here. Overloads shine when the return type actually differs based on the argument:

typescript
function parseConfig(input: string): Record<string, unknown>;
function parseConfig(input: Record<string, unknown>): Record<string, unknown>;
function parseConfig(input: string | Record<string, unknown>): Record<string, unknown> {
  if (typeof input === 'string') {
    return JSON.parse(input);
  }
  return input;
}

Now consider a more realistic scenario from a CRM platform I built, where the return type genuinely depends on the input:

typescript
function fetch<T>(url: string): Promise<T>;
function fetch<T>(url: string, options: { raw: true }): Promise<Response>;
function fetch<T>(url: string, options?: { raw?: boolean }): Promise<T | Response> {
  const request = fetch(url);
  if (options?.raw) {
    return request;
  }
  return request.then(res => res.json());
}

Here, passing { raw: true } changes what you get back. The first overload assumes you want the parsed data. The second one says if you pass the raw flag, you'll get the Response object instead.

Unions — One signature accepting several types

Instead of multiple signatures, you can write a single signature that accepts a union of types. The implementation handles all cases, and the caller has a union return type.

Here's the same price formatting function with unions:

typescript
function formatPrice(value: number, currency: 'USD' | 'BTC' | string): string {
  if (currency === 'USD') {
    return `$${value.toFixed(2)}`;
  }
  if (currency === 'BTC') {
    return `₿${value.toFixed(8)}`;
  }
  return `${currency} ${value}`;
}

This is simpler to read. There's one function signature, one implementation. TypeScript will accept any of those currency strings.

With unions, type narrowing still works inside the function. But at the call site, you lose precision:

typescript
const price: string = formatPrice(100, 'USD');  // Return type is string, which is correct
// But TypeScript doesn't know it's specifically the USD-formatted string

This becomes a real problem in data transformation pipelines. From a ride-hailing platform I maintained, consider this scenario:

typescript
// Union approach
function getUserData(id: number, format: 'json' | 'csv' | 'xml'): string {
  if (format === 'json') {
    return JSON.stringify({ id, name: 'John' });
  }
  if (format === 'csv') {
    return 'id,name\n1,John';
  }
  return '<user><id>1</id></user>';
}

const data = getUserData(1, 'json');
// data is typed as string, but you know it's JSON
// Later, if you try to parse it: JSON.parse(data) works fine
// But TypeScript doesn't enforce that the format matches how you use it

With a union, you lose the connection between the format you requested and what you get back. The return type is just string for all cases.

Prefer unions — Simpler to read and implement

For the majority of cases, start with unions. They're cleaner, less boilerplate, and easier to maintain.

Union types are better when:

  • The return type is the same regardless of input
  • You're just varying the accepted input types
  • The function logic is straightforward and doesn't have deeply different code paths per input type
  • You want the simplest possible type signature

Here's a real example from an e-commerce platform. We had a logging function that accepted different event shapes:

typescript
type PageViewEvent = { type: 'pageview'; url: string; timestamp: number };
type PurchaseEvent = { type: 'purchase'; orderId: string; amount: number };
type UserEvent = PageViewEvent | PurchaseEvent;

function logEvent(event: UserEvent): void {
  console.log(`[${event.type}]`, event.timestamp || event.amount);
}

logEvent({ type: 'pageview', url: '/products', timestamp: 123 });
logEvent({ type: 'purchase', orderId: 'abc', amount: 99.99 });

This is clean and maintainable. One signature, union of input types, simple logic inside.

Use overloads when — The return type depends on the argument type

Reach for overloads when the return type or structure fundamentally changes based on what the caller passes in.

The key signal: the caller needs to know the return type at compile time based on their input.

From a SaaS product I built, here's a real case for overloads:

typescript
function query(sql: string): Promise<unknown[]>;
function query<T>(sql: string, params: Record<string, unknown>): Promise<T[]>;
function query<T>(sql: string, params?: Record<string, unknown>): Promise<unknown[] | T[]> {
  // Execute query...
  return executeQuery(sql, params);
}

// Caller gets precise types
const rows = await query('SELECT * FROM users');  // Promise<unknown[]>
const typedRows = await query<User>('SELECT * FROM users WHERE id = ?', { id: 1 });  // Promise<User[]>

In the second case, you're explicitly passing a type parameter and params. The overload tells TypeScript: if you provide params, you're committing to a type, and you'll get back an array of that type.

Another strong case: conditional return types based on options:

typescript
function createSession(userId: string): Session;
function createSession(userId: string, options: { withToken: true }): Session & { token: string };
function createSession(userId: string, options?: { withToken?: boolean }): Session | (Session & { token: string }) {
  const session: Session = { userId, createdAt: Date.now() };
  if (options?.withToken) {
    return { ...session, token: generateToken() };
  }
  return session;
}

const basic = createSession('user-1');  // type is Session
const withAuth = createSession('user-1', { withToken: true });  // type is Session & { token: string }
// TypeScript prevents you from accessing .token on basic

This is powerful. The caller's experience is type-safe and predictable. You can't accidentally forget to handle the token case because TypeScript knows you have it.

Common mistakes

Mistake 1: Overloads without narrowing in the implementation

typescript
function process(input: string | number): string {
  return input.toUpperCase();  // Error: number has no toUpperCase
}

// Correct:
function process(input: string): string;
function process(input: number): string;
function process(input: string | number): string {
  if (typeof input === 'string') {
    return input.toUpperCase();
  }
  return String(input).padStart(10, '0');
}

Mistake 2: Too many overloads becoming unmaintainable

I've seen trading platforms with 10+ overloads for a single function. At that point, reconsider your design. Break the function into smaller, focused functions or use a configuration object:

typescript
// Before: Too many overloads
function trade(symbol: string, type: 'BUY', quantity: number): Promise<Order>;
function trade(symbol: string, type: 'SELL', quantity: number): Promise<Order>;
function trade(symbol: string, type: 'BUY' | 'SELL', quantity: number, limit: number): Promise<Order>;
// ... more combinations

// After: Cleaner design
interface TradeRequest {
  symbol: string;
  type: 'BUY' | 'SELL';
  quantity: number;
  limit?: number;
}
function trade(request: TradeRequest): Promise<Order> {}

Mistake 3: Forgetting that overload signatures must be compatible

typescript
// This doesn't work:
function getData(id: number): string;
function getData(id: string): number;  // Implementation must accept both!
function getData(id: number | string): string | number {
  // ...
}

// At the call site, TypeScript can't know if you're passing number or string,
// so it can't guarantee the return type.

Mistake 4: Using unions when the caller needs type safety

If you return a union and the behavior changes per type, the caller has to narrow the type themselves. This is friction. Overloads move that burden to you (one-time cost) instead of every caller (repeated cost).

Key takeaways

  • Function overloads give you multiple signatures where the return type and accepted parameters vary independently, allowing the caller to get precise type information at the call site.
  • Union types in a single signature are simpler and cleaner when the return type doesn't fundamentally change based on the input.
  • Start with unions. They're the default. Reach for overloads only when the return type or required behavior differs based on the arguments.
  • The implementation must handle all overload combinations, which means using type guards (like typeof or discriminated unions) to narrow the types safely.
  • Too many overloads is a code smell. If you're writing more than 3–4 overloads, consider breaking the function into smaller, focused functions or using a configuration object instead.
  • Overloads are valuable in libraries and APIs where you control the experience of many callers. The upfront complexity is worth it if it improves type safety across a large codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles