DEV Community

Cover image for Function Overloads in 2026: Use a Discriminated Param Instead
Gabriel Anhaia
Gabriel Anhaia

Posted on

Function Overloads in 2026: Use a Discriminated Param Instead


A team I talked to last month had a query helper that took
three different shapes: a single ID, a list of IDs, or a filter
object. The function had been written with overloads. Three
declared signatures, one implementation signature underneath. It
shipped fine. The bug arrived two months later, in a wrapper
someone built to add caching: the wrapper used
Parameters<typeof query> to mirror the input. The cache thought
query only accepted a filter object. The list-of-IDs path
silently lost its types. Every cached call to that branch was
typed as unknown.

Nobody had touched the original function. The overloads still
read fine in the editor. Every call site still showed the right
hint. The type-system slip was in a place a careful reader does
not look: the implementation signature is invisible to callers,
Parameters<T> only reads the last declared overload, and
generic narrowing across overloads is brittle in ways that do not
show up until you compose a wrapper around them.

Overloads are not broken. They are the right tool for a specific
shape of problem. But the default reach for "this function takes
two things" is a discriminated-union parameter, and the cost of
defaulting to overloads is the kind of bug that ships.

What overloads actually compile to

Here is the canonical overloaded function. Three call shapes,
one body.

function query(id: string): Promise<User>;
function query(ids: string[]): Promise<User[]>;
function query(filter: { role: string }): Promise<User[]>;
function query(
  arg: string | string[] | { role: string },
): Promise<User | User[]> {
  if (typeof arg === "string") {
    return fetchOne(arg);
  }
  if (Array.isArray(arg)) {
    return fetchMany(arg);
  }
  return fetchByFilter(arg);
}
Enter fullscreen mode Exit fullscreen mode

The three top declarations are the public face. The fourth one,
the implementation signature, is invisible from outside the
module. Callers can see only the three above. The body has to
satisfy all four shapes at once. That last constraint is where
the cost lives.

Three concrete things go wrong with this default.

Cost 1: the implementation signature is invisible to callers

When you reach for a typeof reference, the compiler picks the
last declared overload only.

type Q = typeof query;
type P = Parameters<Q>;
//   ^? [filter: { role: string }]

type R = ReturnType<Q>;
//   ^? Promise<User[]>
Enter fullscreen mode Exit fullscreen mode

Parameters<typeof query> does not return a union of the three
overloaded inputs. It returns the last one. This is documented
behaviour, listed in the TypeScript handbook on overloads
and the utility-type page.
Most engineers who reach for Parameters are not reading those
pages at the moment they reach. They get back a type, the type
compiles, the wrapper ships.

This is the bug the team I mentioned hit. The cache wrapper was
typed as

function cached<F extends (...args: any) => any>(
  fn: F,
): (...args: Parameters<F>) => ReturnType<F> {
  // memoize on JSON.stringify(args)
}

const cachedQuery = cached(query);
cachedQuery({ role: "admin" }); // ok
cachedQuery("user-123");        // type error,
                                // string is not assignable
                                // to { role: string }
Enter fullscreen mode Exit fullscreen mode

The wrapper's signature mirrors the last overload. Every other
call site is rejected by the wrapper, even though they were valid
for the original function. The fix the team shipped was a manual
union of all three input shapes inside the wrapper's generic
parameter. Which is exactly what a discriminated-union parameter
would have given them for free.

Cost 2: infer only sees the last overload

Anywhere you write a conditional type that reads the function's
signature, you get the same restriction.

type FirstArg<F> = F extends (a: infer A, ...rest: any) => any
  ? A
  : never;

type Q = FirstArg<typeof query>;
//   ^? { role: string }
Enter fullscreen mode Exit fullscreen mode

The conditional reads only the last overload. The trick to walk
all overloads exists (a depth-N variadic helper that unrolls up
to four signatures), and it is the kind of code that is written
once, copied between projects, and goes stale every time
TypeScript adds a new way to express function types. The
standard library's own utility types
do not unroll overloads. Neither does any major typed-API
library I have read. If your infer-based extraction has to
walk every signature of an overloaded function, the function
should not have been overloaded.

Cost 3: implementation-signature drift

The body has to be assignable to a union that covers every
overloaded input. Once that union grows past two members, the
body's parameter type starts looking like a kitchen sink.

function query(
  arg: string | string[] | { role: string },
): Promise<User | User[]> {
  // body has to switch on the shape and convince the compiler
  // each branch returns the right output for the right input
}
Enter fullscreen mode Exit fullscreen mode

The compiler does not check the body against any of the three
public overloads. It checks the body against the implementation
signature only. If you add a fourth overload but forget to widen
the implementation signature, the new overload is unreachable
(its inputs do not satisfy the old union), but the compiler does
not warn. The four declared overloads still autocomplete. The
fourth one is dead at runtime.

This is the variant nobody catches in code review. The overloads
are public; the implementation is private. The drift is between
something visible and something invisible.

overloads vs discriminated-union flow

The discriminated-param replacement

Take the same function. Drop the overloads. Replace them with a
single signature whose parameter is a discriminated union.

type QueryArgs =
  | { kind: "byId"; id: string }
  | { kind: "byIds"; ids: string[] }
  | { kind: "byFilter"; filter: { role: string } };

type QueryResult<A extends QueryArgs> =
  A extends { kind: "byId" } ? Promise<User> :
  A extends { kind: "byIds" } ? Promise<User[]> :
  A extends { kind: "byFilter" } ? Promise<User[]> :
  never;

function query<A extends QueryArgs>(args: A): QueryResult<A> {
  switch (args.kind) {
    case "byId":
      return fetchOne(args.id) as QueryResult<A>;
    case "byIds":
      return fetchMany(args.ids) as QueryResult<A>;
    case "byFilter":
      return fetchByFilter(args.filter) as QueryResult<A>;
  }
}

const a = await query({ kind: "byId", id: "123" });
//    ^? User
const b = await query({ kind: "byIds", ids: ["1", "2"] });
//    ^? User[]
Enter fullscreen mode Exit fullscreen mode

Three things change at once.

Parameters<typeof query> returns the entire union, not the
last overload. The cached wrapper from earlier works without
manual signature gymnastics.

infer against the function reads the union. Conditional types
extract the discriminant directly: A extends { kind: "byId" }
narrows the result. No depth-N helper. No copy-pasted four-deep
unroll.

The implementation signature does not exist. There is one
signature, and it is the public one. Drift between "what the
caller sees" and "what the body checks against" is gone — they
are the same type.

The cost of the change is at the call site. Callers go from
query("123") to query({ kind: "byId", id: "123" }). That is
real friction. It is the main reason overloads still win for
some surfaces, which is the next section.

Where overloads still win

Three cases where the discriminated-param replacement is wrong.

The DOM and other JS-history-bound surfaces. addEventListener
takes a string event name and a handler typed against that
event. document.querySelector("input") returns
HTMLInputElement | null, while document.querySelector("div")
returns HTMLDivElement | null. The string-literal-type-keyed
overloads are the right tool here: there is no discriminant
property to add because the input is a single primitive. The
lib.dom.d.ts
file has thousands of these and they are correct.

Tag functions where the literal type itself is the
discriminator.
A logger that takes
logger("start" | "end" | "error", payload) is more naturally
written as overloads than as a union, because the second
parameter's shape depends on the literal type of the first, and
the discriminant is already there in the call shape — adding a
kind field would be ceremonial.

function emit(event: "start", payload: { runId: string }): void;
function emit(
  event: "error",
  payload: { runId: string; error: Error },
): void;
function emit(event: "end", payload: { runId: string }): void;
function emit(event: string, payload: unknown): void {
  bus.publish(event, payload);
}

emit("error", { runId: "r1", error: new Error("boom") });
Enter fullscreen mode Exit fullscreen mode

This reads cleanly at the call site, and the literal-type
narrowing on event does the work the discriminant property
would do otherwise.

External APIs you cannot change. If you are writing the
.d.ts for a library that already ships with overloaded
JavaScript, you mirror what the runtime accepts. The author of
the .d.ts is not free to redesign the call shape.

call-site cost vs type-system clarity

The rule of thumb

Reach for a discriminated-param signature when:

  • The call shape is more than two overloads.
  • A wrapper, decorator, or higher-order function will mirror the signature later. (Caching, retry, instrumentation, request batching, an RPC bridge.)
  • The return type depends on the input shape and you would otherwise need a depth-N infer helper.
  • The function is internal to your codebase, so the call-site cost of { kind: ..., ... } is negotiable.

Stay with overloads when:

  • The discriminant is already a string literal at parameter position 1 (events, query selectors, RPC dispatch).
  • You are mirroring a public JavaScript API surface that ships with overloads.
  • The two overloads are stable and unlikely to grow. The cost of the wrapper bug is real, but it is a cost only paid when someone composes around the function.

The decision rule lives at the place wrappers will read the
type. If the function is going to be wrapped, decorated, or
extracted from, the discriminated param wins. If the function is
a leaf (a final call into a third-party surface, or a logger
that nothing higher-order ever sees), overloads are fine.

The team refactored the query function. The cache wrapper lost
its generic gymnastics and got the entire input union for free.
New call shapes were added later without anyone having to widen
the implementation signature.

The overloads were not the bug. The overloads were a reach for
"this function takes a few things," when the type system has a
more direct way to say it.


If this was useful

The function-shape decision above is the kind of choice
TypeScript Essentials covers: which feature of the language to
reach for when more than one would compile, and which choice
survives the wrapper, the decorator, or the generic helper that
arrives six months later. The book covers overloads alongside
discriminated unions, generic constraints, and the call-site
ergonomics that decide which one is the right default.

The type-system reasons overloads are this awkward go deeper
than this post can: the last-overload restriction on infer,
the implementation-signature invisibility, the depth-N unroll
trick. That is The TypeScript Type System's territory.
Conditional types, mapped types, and the infer mechanics make
it possible to extract function shapes correctly when you do
need to walk every overload. The
two books are the core path of the collection; the others
substitute for them if you are bridging from JVM or PHP, or pick
up where both end with library-authoring concerns.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)