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");
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);
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
);
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
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;
But not this:
type All =
(
(() => void)
|
((a: number) => string)
|
((a: string, b: number) => boolean)
);
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);
I also tried the opposite order:
declare const aRight: ((a: string, b: number) => boolean) & A;
And that order is the important part.
Logically, neither type should change much.
A already has this signature:
(a: string, b: number): boolean;
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);
And they do.
But then I checked IntelliSense.
For plain A, VS Code showed the first overload as:
a(): void
For this version:
type Left = A & ((a: string, b: number) => boolean);
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;
VS Code showed the first overload as:
aRight(a: string, b: number): boolean
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
And there it is.
Before the intersection, TypeScript inferred:
(a: string, b: number) => boolean
After intersecting that same signature back into A, TypeScript inferred:
(a: number) => string
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:
- Infer the currently visible last call signature.
- Add that signature into an intersection alignment.
- Let TypeScript expose another signature on the next pass.
- 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, {}>;
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
: ...
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
And then recurse:
InternalCallSignatures
<
T_Alignment & T_Callable,
((...parameters: T_Parameters) => T_Result) & T_Alignment
>
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] }
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>;
And the result is:
type AllCallSignatures =
(
(() => void)
|
((a: number) => string)
|
((a: string, b: number) => boolean)
);
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
);
Because conditional types distribute over unions, this works naturally:
type AsyncCallSignatures = PromisifySignature<CallSignatures<A>>;
Result:
type AsyncCallSignatures =
(
(() => Promise<void>)
|
((a: number) => Promise<string>)
|
((a: string, b: number) => Promise<boolean>)
);
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
);
And build an overloaded callable type again:
type AsyncA = UnionToIntersection<AsyncCallSignatures>;
Usage:
declare const asyncA: AsyncA;
const result1 = asyncA();
// ^? Promise<void>
const result2 = asyncA(123);
// ^? Promise<string>
const result3 = asyncA("hello", 1);
// ^? Promise<boolean>
So now the full pipeline is:
// overloads -> union -> transform -> intersection -> overloads again
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)