Skip to main content

TS Ep 13: Function Overloads — Polymorphic Signatures

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 13: This Article
In JavaScript, a single function can behave completely differently depending on the number and type of arguments passed to it. Function Overloads allow you to describe these complex polymorphic relationships by declaring multiple type signatures for a single function implementation.

1. The Need for Function Overloads
#

Suppose you are building an HTTP client utility fetchData.

  • If called with a string URL (e.g., fetchData("https://api.com/users")), it returns a single User object.
  • If called with an array of URLs (e.g., fetchData(["url1", "url2"])), it returns an array of User[] objects.

If you attempt to write this using basic union parameters and return types:

// ❌ Problematic Union Signature
function fetchData(urlOrUrls: string | string[]): User | User[] {
  if (typeof urlOrUrls === "string") {
    return { id: "1", name: "Alice" };
  }
  return [{ id: "1", name: "Alice" }];
}

const single = fetchData("https://api.com/users");
// ❌ Error: 'single' is typed as 'User | User[]'! 
// TypeScript doesn't know that passing a string guarantees receiving a single User back!
// console.log(single.name); // Error: Property 'name' does not exist on type 'User[]'.

Because the function return type is User | User[], the caller is forced to write annoying runtime narrowing checks on single, even though we know a single string parameter always returns a single User.


2. Anatomy of a Function Overload
#

To solve this, we define two or more Overload Signatures (signature declarations without a function body), followed by the Implementation Signature (which contains the function body).

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

// -----------------------------------------------------------------
// 1. OVERLOAD SIGNATURES (Publicly visible to callers)
// -----------------------------------------------------------------

/** Fetch a single user by URL string */
function fetchData(url: string): User;

/** Fetch multiple users by an array of URL strings */
function fetchData(urls: string[]): User[];

// -----------------------------------------------------------------
// 2. IMPLEMENTATION SIGNATURE (Hidden from callers!)
// -----------------------------------------------------------------
function fetchData(urlOrUrls: string | string[]): User | User[] {
  if (typeof urlOrUrls === "string") {
    return { id: "usr_101", name: "Alice" };
  }
  return [
    { id: "usr_101", name: "Alice" },
    { id: "usr_102", name: "Bob" },
  ];
}

How Callers See the Function
#

Now, when a developer calls fetchData, TypeScript matches the argument types against the overload signatures:

// 🟢 Matches Overload 1: Inferred type is strictly 'User'!
const user = fetchData("https://api.com/user/101");
console.log(user.name.toUpperCase()); // Safe! Perfect autocomplete!

// 🟢 Matches Overload 2: Inferred type is strictly 'User[]'!
const users = fetchData(["https://api.com/u1", "https://api.com/u2"]);
console.log(users.map((u) => u.name)); // Safe!

3. The Hidden Implementation Signature Rule
#

Important

Critical Rule: The Implementation Signature is invisible to callers outside the function! Callers can ONLY invoke overload signatures.

Consider what happens if someone attempts to call fetchData with a variable typed as string | string[]:

const dynamicUrl: string | string[] = "https://api.com/users";

// ❌ Compiler Error: No overload matches this call.
// Overload 1 of 2 expects 'string', but got 'string | string[]'.
// Overload 2 of 2 expects 'string[]', but got 'string | string[]'.
// fetchData(dynamicUrl);

Even though the Implementation Signature accepts string | string[], callers cannot use it because the Implementation Signature is unexposed!

To support string | string[], you must add an explicit 3rd Overload Signature:

// Overload 1
function fetchData(url: string): User;
// Overload 2
function fetchData(urls: string[]): User[];
// Overload 3 (Explicitly permits union parameters!)
function fetchData(urlOrUrls: string | string[]): User | User[];

// Implementation
function fetchData(urlOrUrls: string | string[]): User | User[] {
  // ...
}

4. Overload Order Matters!
#

TypeScript evaluates overload signatures sequentially from top to bottom, picking the first signature that matches the argument types.

Always list your most specific overload signatures at the top, and your broadest signatures at the bottom:

// ❌ WRONG ORDER: Broad signature at the top swallows specific calls!
function processBuffer(input: any): any;
function processBuffer(input: string): string; // Never reached!

// 🟢 CORRECT ORDER: Specific signatures first, broad signatures last!
function processBuffer(input: string): string;
function processBuffer(input: number[]): number[];
function processBuffer(input: unknown): unknown; // Fallback

5. Overloads vs Union Types vs Generics
#

Do not use Overloads when a simple Union Type or Generic will suffice. Overloads add verbosity.

When NOT to use Overloads:
#

// ❌ UNNECESSARY OVERLOADS:
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any): number {
  return x.length;
}

// 🟢 BETTER: Use Union Types!
function lenBetter(x: string | any[]): number {
  return x.length;
}
Use CaseRecommended ApproachWhy?
Return type changes based on argument typeFunction Overloads (or Conditional Types)Maps input types to discrete return types
Arguments accept multiple types, return type is identicalUnion Types (a: string | number)Simpler signature, cleaner maintenance
Return type equals the exact input type passed inGenerics (fn<T>(x: T): T)Preserves type identity dynamically

Summary & Next Steps
#

In this episode:

  • We demonstrated how Function Overloads solve return type loss in polymorphic functions.
  • We analyzed the Hidden Implementation Signature Rule: Callers can only see declared overload signatures.
  • We learned that overload signatures must be ordered from most specific to broadest.
  • We compared Overloads vs Unions vs Generics.

In Episode 14: The Never Type and Exhaustive Checks, we will explore TypeScript’s bottom type (never) and enforce compile-time exhaustive switch checks!

typescript - This article is part of a series.
Part 13: This Article