TypeScript Function Overloads: Multiple Signatures

JavaScript doesn't support function overloading in the traditional sense. You can't define multiple functions with the same name but different parameter lists. Instead, JavaScript functions accept...

Key Insights

  • Function overloads let you define multiple type signatures for a single function, enabling precise type inference based on how the function is called
  • The implementation signature must be compatible with all overload signatures, but it’s not visible to callers—only the overload signatures are
  • Overloads shine when return types depend on input types, but union types are often simpler and should be your first choice

Introduction to Function Overloads

JavaScript doesn’t support function overloading in the traditional sense. You can’t define multiple functions with the same name but different parameter lists. Instead, JavaScript functions accept any number of arguments, and you handle variations internally with conditional logic.

TypeScript adds static typing to this dynamic behavior through function overloads. They let you declare multiple type signatures for a single function, giving you precise type checking and IntelliSense for different call patterns.

Here’s the problem overloads solve. Without them, you end up with overly broad types:

function process(input: string | number): string | number {
  if (typeof input === 'string') {
    return input.toUpperCase();
  }
  return input * 2;
}

const result1 = process("hello"); // Type: string | number (too broad!)
const result2 = process(42);       // Type: string | number (too broad!)

The return type is string | number even though we know strings return strings and numbers return numbers. This forces unnecessary type narrowing at call sites.

Basic Overload Syntax

Function overloads consist of multiple signature declarations followed by a single implementation. The overload signatures define the public API, while the implementation signature handles all cases internally.

// Overload signatures
function process(input: string): string;
function process(input: number): number;

// Implementation signature
function process(input: string | number): string | number {
  if (typeof input === 'string') {
    return input.toUpperCase();
  }
  return input * 2;
}

const result1 = process("hello"); // Type: string
const result2 = process(42);       // Type: number

Now TypeScript correctly infers the return type based on the input type. The implementation signature must be compatible with all overload signatures—it needs to accept all possible parameter combinations and return all possible return types.

Here’s a practical example with a formatting function:

function format(value: string): string;
function format(value: number): string;
function format(value: boolean): string;
function format(value: string | number | boolean): string {
  if (typeof value === 'boolean') {
    return value ? 'Yes' : 'No';
  }
  if (typeof value === 'number') {
    return value.toFixed(2);
  }
  return value.trim();
}

format("  hello  "); // "hello"
format(42.567);      // "42.57"
format(true);        // "Yes"

Overloads with Different Parameter Counts

Overloads really prove their worth when functions accept different numbers of parameters. This is common in API design where you provide convenience variations of the same operation.

interface ElementProps {
  className?: string;
  id?: string;
}

function createElement(tag: string): HTMLElement;
function createElement(tag: string, props: ElementProps): HTMLElement;
function createElement(tag: string, props: ElementProps, children: string[]): HTMLElement;
function createElement(
  tag: string,
  props?: ElementProps,
  children?: string[]
): HTMLElement {
  const element = document.createElement(tag);
  
  if (props) {
    if (props.className) element.className = props.className;
    if (props.id) element.id = props.id;
  }
  
  if (children) {
    children.forEach(child => {
      element.appendChild(document.createTextNode(child));
    });
  }
  
  return element;
}

// All valid calls with proper type checking
const div1 = createElement('div');
const div2 = createElement('div', { className: 'container' });
const div3 = createElement('div', { id: 'main' }, ['Hello', 'World']);

This differs from optional parameters. With optional parameters, all combinations are valid. With overloads, you explicitly control which combinations are allowed:

// With overloads: only specific combinations allowed
function connect(host: string): Connection;
function connect(host: string, port: number): Connection;
function connect(host: string, port: number, credentials: Credentials): Connection;

// With optional parameters: all combinations allowed
function connect(host: string, port?: number, credentials?: Credentials): Connection;

The overload approach prevents invalid states like passing credentials without a port if that doesn’t make sense for your API.

Return Type Narrowing

The most powerful use of overloads is narrowing return types based on input parameters. This eliminates type assertions and makes your code safer.

function query(selector: string): Element | null;
function query(selector: string[]): Element[];
function query(selector: string | string[]): Element | Element[] | null {
  if (Array.isArray(selector)) {
    return selector.map(s => document.querySelector(s)).filter(Boolean) as Element[];
  }
  return document.querySelector(selector);
}

const single = query('.button');      // Type: Element | null
const multiple = query(['.a', '.b']); // Type: Element[]

This pattern is especially useful for configuration-based APIs where a parameter determines the return type:

interface Config {
  parse: boolean;
}

function fetchData(url: string, config: { parse: true }): object;
function fetchData(url: string, config: { parse: false }): string;
function fetchData(url: string, config?: { parse: boolean }): string;
function fetchData(url: string, config?: Config): object | string {
  const response = fetch(url).then(r => r.text());
  
  if (config?.parse) {
    return response.then(text => JSON.parse(text));
  }
  return response;
}

const parsed = fetchData('/api/data', { parse: true });   // Type: object
const raw = fetchData('/api/data', { parse: false });     // Type: string
const default = fetchData('/api/data');                   // Type: string

Common Patterns and Best Practices

Before reaching for overloads, consider if union types suffice. They’re simpler and often clearer:

// Union type approach - simpler when return type doesn't vary
type Input = string | number;

function process(input: Input): string {
  return typeof input === 'string' ? input.toUpperCase() : input.toString();
}

// Overload approach - better when return type varies with input
function process(input: string): string;
function process(input: number): number;
function process(input: string | number): string | number {
  // implementation
}

Use overloads when:

  • Return types depend on input types
  • You need to enforce specific parameter combinations
  • You want to hide implementation details from the public API

When defining overloads, order matters. List signatures from most specific to least specific. TypeScript uses the first matching signature:

// Correct: specific before general
function handle(value: string): string;
function handle(value: number): number;
function handle(value: any): any;

// Wrong: general signature matches everything first
function handle(value: any): any;
function handle(value: string): string;  // Never reached!
function handle(value: number): number;  // Never reached!

Avoid overloads when optional parameters or rest parameters work just as well:

// Don't use overloads for this
function log(message: string): void;
function log(message: string, level: string): void;

// Use optional parameters instead
function log(message: string, level?: string): void {
  console.log(`[${level ?? 'INFO'}] ${message}`);
}

Pitfalls and Limitations

The implementation signature is invisible to callers. This catches many developers off guard:

function process(input: string): string;
function process(input: number): number;
function process(input: string | number): string | number {
  // implementation
}

// Error: No overload matches this call
process("hello" as string | number);

Even though the implementation accepts string | number, callers can only use the overload signatures. This is by design—the implementation signature is an internal detail.

A common mistake is making the implementation signature too narrow:

function format(value: string): string;
function format(value: number): string;
// Error: This signature is not compatible with overloads
function format(value: string): string {
  return value.toString();
}

The implementation must handle all cases. It needs to accept both string and number:

function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
  return value.toString();
}

Inside the implementation, you work with the union type. You’ll need type guards to narrow types:

function process(input: string): string;
function process(input: number): number;
function process(input: string | number): string | number {
  if (typeof input === 'string') {
    return input.toUpperCase(); // input is string here
  }
  return input * 2; // input is number here
}

Function overloads are a powerful TypeScript feature for creating type-safe APIs with multiple call signatures. They excel at return type narrowing and enforcing specific parameter combinations. However, they add complexity—use them judiciously and prefer simpler solutions like union types when they suffice. When you do use overloads, remember that the implementation signature is internal-only and must be compatible with all public overload signatures.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.