DEV Community

Cover image for TypeScript Infers the Last Overload... So I Changed the Order
NickM
NickM

Posted on

TypeScript Infers the Last Overload... So I Changed the Order

Once I was working with i18next and wanted to get better inference from translation keys.

Usually, with translation libraries, we pass keys as strings:

translate("user.profile.title");
Enter fullscreen mode Exit fullscreen mode

And strings are fine... until they are not.

I wanted something closer in spirit to C# expression trees. Not real expression trees, of course — JavaScript does not have those. But something like this:

translate($ => $.user.profile.title);
Enter fullscreen mode Exit fullscreen mode

Under the hood, this could be handled with a Proxy, reading the full member-access path at runtime.

Why even bother?

Because a lambda selector can give you much better DX than a plain string:

  • you can navigate to the source JSON/schema field;
  • you can navigate to the translation schema definition;
  • you can rename fields with editor refactoring;
  • you can get autocomplete through the whole translation tree.

Cool idea. But then I had to answer a very annoying TypeScript question.

I needed to take an existing function type from i18next, replace one of its argument types, and preserve the rest of the API.

Sounds simple enough, right?

Except that function type had several call signatures.

In other words: overloads.

And that led me to the real question:

Can we extract all call signatures from an overloaded function type, transform them, and build the overloaded type back?

I searched around and mostly found hardcoded overload tables, like “support up to 5 overloads”, “support up to 10 overloads”, etc.

That was not exactly what I wanted.

So I started experimenting.

The first wall: TypeScript infers only one overload

Let’s start with a tiny helper:

type InferCallSignature<T_Callable> =
(
    T_Callable extends (...args: infer T_Parameters) => infer T_Result
        ? ((...parameters: T_Parameters) => T_Result)
        : never
);
Enter fullscreen mode Exit fullscreen mode

Nothing special here. If T_Callable is callable, infer its parameters and return type, then rebuild a normal function type.

Now let’s try it with an overloaded callable type:

type A =
{
    (): void;
    (a: number): string;
    (a: string, b: number): boolean;
};


type B = InferCallSignature<A>;
//   ^? type B = (a: string, b: number) => boolean
Enter fullscreen mode Exit fullscreen mode

TypeScript inferred only one call signature.

More specifically, it inferred the last call signature.

That is the documented behavior for overloaded functions: when inferring from an overloaded type, TypeScript uses the last signature.

So at first glance, we are stuck.

We can get this:

type B = (a: string, b: number) => boolean;
Enter fullscreen mode Exit fullscreen mode

But not this:

type All =
(
    (() => void)
        | 
    ((a: number) => string)
        | 
    ((a: string, b: number) => boolean)
);

Enter fullscreen mode Exit fullscreen mode

And without such a union, we cannot easily transform every overload.

Then I tried an intersection

At this point, I started thinking about order.

We already know call-signature order matters: TypeScript infers from the last overload. So I wanted to poke the only thing I could still poke: intersection order.

That is what made me try something that looked completely redundant: intersecting the overloaded type with one of its existing call signatures.

But I did not only try this order:

declare const aLeft: A & ((a: string, b: number) => boolean);
Enter fullscreen mode Exit fullscreen mode

I also tried the opposite order:

declare const aRight: ((a: string, b: number) => boolean) & A;
Enter fullscreen mode Exit fullscreen mode

And that order is the important part.

Logically, neither type should change much.

A already has this signature:

(a: string, b: number): boolean;
Enter fullscreen mode Exit fullscreen mode

So all of these should behave the same when called:

declare const a: A;

a();
a(123);
a("hello", 1);

aLeft();
aLeft(123);
aLeft("hello", 1);

aRight();
aRight(123);
aRight("hello", 1);
Enter fullscreen mode Exit fullscreen mode

And they do.

But then I checked IntelliSense.

For plain A, VS Code showed the first overload as:

a(): void
Enter fullscreen mode Exit fullscreen mode

For this version:

type Left = A & ((a: string, b: number) => boolean);
Enter fullscreen mode Exit fullscreen mode

it still looked functionally the same as A: the same call signatures, in the same order.

But for the opposite order:

type Right = ((a: string, b: number) => boolean) & A;
Enter fullscreen mode Exit fullscreen mode

VS Code showed the first overload as:

aRight(a: string, b: number): boolean
Enter fullscreen mode Exit fullscreen mode

Wait.

The left operand of & was placed in front of the right operand in the call-signature order.

That was the interesting part.

The call surface looked the same, but the overload order changed. And even more importantly, A & Signature was not behaving the same as Signature & A for overload ordering.

Why order matters

Remember the previous limitation:

TypeScript infers from the last call signature.

So if intersections can change overload order, maybe we can control what infer sees.

Let’s test it:

type LastCallSignature = InferCallSignature
<
    ((a: string, b: number) => boolean) & A
>;

//   ^? type LastCallSignature = (a: number) => string
Enter fullscreen mode Exit fullscreen mode

And there it is.

Before the intersection, TypeScript inferred:

(a: string, b: number) => boolean
Enter fullscreen mode Exit fullscreen mode

After intersecting that same signature back into A, TypeScript inferred:

(a: number) => string
Enter fullscreen mode Exit fullscreen mode

So the trick is:

TypeScript infers the last overload. Intersections can change the overload alignment. Therefore, intersections can change what TypeScript infers.

That is the whole door.

Once I saw that, extracting all call signatures became possible.

The plan

If TypeScript gives us only one call signature at a time, we can do this recursively:

  1. Infer the currently visible last call signature.
  2. Add that signature into an intersection alignment.
  3. Let TypeScript expose another signature on the next pass.
  4. Repeat until the alignment already satisfies the original callable type.

The utility type

Here is the full version:

type InternalCallSignatures<T_Callable, T_Alignment> =
(
    ({ [key in keyof T_Callable]: T_Callable[key] } & T_Alignment) extends T_Callable
        ? never
        : T_Callable extends (...args: infer T_Parameters) => infer T_Result
            ?
            (
                ((...parameters: T_Parameters) => T_Result)
                    |
                InternalCallSignatures
                <
                    T_Alignment & T_Callable,
                    ((...parameters: T_Parameters) => T_Result) & T_Alignment
                >
            )
            : never
);

export type CallSignatures<T_Callable> =
    InternalCallSignatures<T_Callable, {}>;
Enter fullscreen mode Exit fullscreen mode

Let’s unpack it.

T_Callable is the callable type we are currently inspecting.

T_Alignment is the accumulated intersection of signatures we have already discovered. Think of it as a type-level alignment state. It influences which overload TypeScript will infer next.

This part is the stop condition:

({ [key in keyof T_Callable]: T_Callable[key] } & T_Alignment) extends T_Callable
    ? never
    : ...
Enter fullscreen mode Exit fullscreen mode

In human words:

Does the current alignment already satisfy the callable shape we are inspecting?

If yes, we are done, so we return never.

If not, we infer one more call signature:

T_Callable extends (...args: infer T_Parameters) => infer T_Result
    ? ((...parameters: T_Parameters) => T_Result)
    : never
Enter fullscreen mode Exit fullscreen mode

And then recurse:

InternalCallSignatures
<
    T_Alignment & T_Callable,
    ((...parameters: T_Parameters) => T_Result) & T_Alignment
>
Enter fullscreen mode Exit fullscreen mode

On every step, we return the inferred signature as part of a union, and we extend the alignment with that signature.

The mapped type here:

{ [key in keyof T_Callable]: T_Callable[key] }
Enter fullscreen mode Exit fullscreen mode

may look useless, but it helps normalize the non-callable/object part of the type before checking it together with the current alignment.

The result

Now let’s try it with our overloaded type:

type A =
{
    (): void;
    (a: number): string;
    (a: string, b: number): boolean;
};


type AllCallSignatures = CallSignatures<A>;
Enter fullscreen mode Exit fullscreen mode

And the result is:

type AllCallSignatures =
(
    (() => void)
        | 
    ((a: number) => string)
        | 
    ((a: string, b: number) => boolean)
);
Enter fullscreen mode Exit fullscreen mode

Beautiful.

Now the overloads are not trapped inside an overloaded callable type anymore.

They are a union.

And unions are much easier to transform.

Transforming the signatures

For example, let’s wrap every return type into Promise:

type PromisifySignature<T_Signature> =
(
    T_Signature extends (...args: infer T_Parameters) => infer T_Result
        ? (...parameters: T_Parameters) => Promise<T_Result>
        : never
);
Enter fullscreen mode Exit fullscreen mode

Because conditional types distribute over unions, this works naturally:

type AsyncCallSignatures = PromisifySignature<CallSignatures<A>>;
Enter fullscreen mode Exit fullscreen mode

Result:

type AsyncCallSignatures =
(
    (() => Promise<void>)
        | 
    ((a: number) => Promise<string>)
        | 
    ((a: string, b: number) => Promise<boolean>)
);
Enter fullscreen mode Exit fullscreen mode

Now we can convert the union back into an intersection:

type UnionToIntersection<T_Union> =
(
    (
        T_Union extends unknown
            ? (value: T_Union) => void
            : never
    ) extends (value: infer T_Intersection) => void
        ? T_Intersection
        : never
);
Enter fullscreen mode Exit fullscreen mode

And build an overloaded callable type again:

type AsyncA = UnionToIntersection<AsyncCallSignatures>;
Enter fullscreen mode Exit fullscreen mode

Usage:

declare const asyncA: AsyncA;

const result1 = asyncA();
//    ^? Promise<void>

const result2 = asyncA(123);
//    ^? Promise<string>

const result3 = asyncA("hello", 1);
//    ^? Promise<boolean>
Enter fullscreen mode Exit fullscreen mode

So now the full pipeline is:

// overloads -> union -> transform -> intersection -> overloads again
Enter fullscreen mode Exit fullscreen mode

Back to the original problem

In my i18next experiment, this meant I could theoretically take overloaded function signatures, replace one argument type, and preserve the rest of the overload behavior.

The important part was not i18next itself.

The important part was realizing this:

If you can turn overloads into a union, you can operate on them like data.

And the key that made it possible was surprisingly small:

TypeScript infers the last overload, but intersections can change overload order.

Final note

This is definitely a type-system trick, not an official “overload reflection API”.

It relies on how TypeScript currently evaluates overloaded and intersected callable types. So if you use something like this in a library, test it carefully with the TypeScript versions you support.

Still, I think it is a beautiful little trick.

I started with translation keys and ended up learning that & can do much more than just merge object types.

Sometimes TypeScript is weird in exactly the right way.

Top comments (0)