Extracting Function Argument Types in TypeScript
Pulling parameters and return types out of a function with `infer`, and where it stops working on unions.
~/posts/typescript-function-argument-types $ cat post.md
Setup
Bark Core needed an event-style hook. Consumers register callbacks into a class; at certain points the framework looks up whether a key is registered and, if so, fires its callback.
On the TS side we obviously want the type system to do real work.
Anyone familiar with TS will think of @types/node’s event types
first — the on(key: string, fn: ...) pattern already has a known
shape there. It doesn’t quite fit our case. Let me walk through the
canonical on first, then what we actually needed.
The standard on
The .d.ts source isn’t worth pasting. It’s basically a wall of
overloads on on, Java-style. It’s not great to extend and it’s not
great to read either. Let’s write something motivated by our
scenario.
We have a key → value table. When on’s first argument is T extends KEYS, the second argument should be VALUES[T]. VALUES[T]
is a callback that takes some parameters and returns void.
enum KEYS {
HELLO = "HELLO",
}
type VALUES = {
[KEYS.HELLO]: (a: string) => void;
};
on is then:
class A {
public on<T extends KEYS>(key: T, callback: VALUES[T]) {
// ...add listener
}
}
Proxy call
A typical event-style API is JS underneath, and adding types isn’t
hard. What we want is a proxy call — class B has a call that
takes the key and the arguments and forwards them to class A’s
call:
class B {
public call<T extends KEYS>(key: T, ...args: /* Arguments of VALUES[T] */) {
a.call(...args);
}
}
What we need is a utility type that extracts the parameter list out
of VALUES[T] — call it Arguments of VALUES[T].
Extracting with infer
TypeScript 3.0 introduced the infer keyword. Think of it as a
placeholder marking where you want a type pulled out. With it:
type FunctionArguments<T> = T extends (...args: infer U) => any ? U : never;
Put infer in the return position and you get the return-type
extractor:
type FunctionReturn<T> = T extends (...args: any) => infer U ? U : never;
This worked well for a while. Then I hit a TS behavior — possibly a bug, possibly an implementation limit (TS 3.3.3333 at the time).
The wall
TS resolves infer over union types in a way that bites here:
class A {
public on<T extends KEYS>(key: T, callback: VALUES[T]) {
// ...
}
}
class B {
public call<T extends KEYS>(key: T, ...args: FunctionArguments<VALUES[T]>) {
a.call(...args);
}
}
Two problems:
- TS doesn’t treat
FunctionArguments<VALUES[T]>as “definitely a tuple” — even though by definition the argument list always is one. - When
VALUEShas multiple keys:
type VALUES = {
[KEYS.HELLO]: (a: string) => void;
[KEYS.WORLD]: () => void;
[KEYS.SOMETHING]: (b: string, c: number) => void;
};
In theory, FunctionArguments<VALUES[KEYS.WORLD]> should be [].
In practice, regardless of which T you pick, TS resolves it to
[string] | [] | [string, number] — the union of every key’s
arguments. That makes it unusable.
Flip it: store the arguments directly
If extracting is unreliable, store the argument list itself in
VALUES:
type VALUES = {
[KEYS.HELLO]: [string];
[KEYS.WORLD]: [];
[KEYS.SOMETHING]: [string, number];
};
This was the cleanest option at the time. The cost is losing the
return type — to keep it, either define two parallel tables (one for
args, one for return), or go back to infer.