back to index

Extracting Function Argument Types in TypeScript

Pulling parameters and return types out of a function with `infer`, and where it stops working on unions.

published Mar 26, 2019 tags #typescript #type-system

~/posts/typescript-function-argument-types $ cat post.md

/ LANG EN / 中文
/ THEME / /

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:

  1. TS doesn’t treat FunctionArguments<VALUES[T]> as “definitely a tuple” — even though by definition the argument list always is one.
  2. When VALUES has 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.

back to index