1

I can't figure out a way how to type a wrapper function. I'm creating a utility that will add a console.log to an onChange callback of a form control. What am I missing here?

interface TextInput {
    type: 'TextInput';
    onChange: (value: string) => void;
}

interface NumberInput {
    type: 'NumberInput';
    onChange: (value: number) => void;
}

type FormControl = TextInput | NumberInput;

export const withLogger = <TFormControl extends FormControl>(formControl: TFormControl): TFormControl => ({
    ...formControl,
    onChange: (...args: Parameters<TFormControl['onChange']>) => {
        console.log(formControl.type, 'onChange', args);
        // Error: A spread argument must either have a tuple type or be passed to a rest parameter.(2556)
        return formControl.onChange(...args);
    },
});

I tried multiple ways of typing the args of the onChange wrapper including:

onChange: (value: any) => formControl.onChange(value)
onChange: <TValue>(value: TValue) => formControl.onChange(value)
onChange: (value: number | string) => formControl.onChange(value)

These would fail with Argument of type 'string | number' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'.(2345)

Edit: TypeScript playground link

  • This is essentially a limitation with generic conditional types, and the recommended approach is to refactor to use generic mapped types and indexed accesses, as described in [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109) and shown for your example [in this playground link](https://tsplay.dev/we6gVN). Does that fully address your question? If so I could write up an answer explaining; if not, what am I missing? – jcalz Mar 17 '23 at 15:48
  • Wow I didn’t know of this limitation. This answers it, thanks! – no drama llama Mar 17 '23 at 16:14

1 Answers1

1

The TypeScript compiler is not able to do much analysis on conditional types that depend on generic type parameters. Since the Parameters<T> utility type is implemented as a conditional type, and since TFormControl is an as-yet-unspecified generic type parameter, the compiler really has no idea what Parameters<TFormControl['onChange']> might be, and whether it might be allowed as the parameter list to a function of type TFormControl['onChange']. The compiler doesn't give any significance to the identifier Parameters, and it can't "see into" the definition of Parameters<T> in an abstract way. If T is some specific function type, then Parameters<T> can be evaluated and checked against T.

So in the compiler's attempt to analyze formControl.onChange(...args), it ends up widening formControl from the generic TFormControl to the nongeneric FormControl, which is of the union type ((value: string) => void) | ((value: number) => void), and a union of functions is essentially uncallable.


The recommended approach to something like this is to refactor so that your generic operations are represented by indexed accesses into homomorphic mapped types (see What does "homomorphic mapped type" mean? if that term needs explaining) instead of conditional types. This technique is described in detail in microsoft/TypeScript#47109.

First you write a simple mapping object type which connects the literal "TextInput" and "NumberInput" types to their corresponding parameter list types:

interface InputArgs {
    TextInput: [value: string];
    NumberInput: [value: number];
}

Then we can define Input<K> as a distributive object type over K (where we index into a mapped type over K to get a union):

type Input<K extends keyof InputArgs = keyof InputArgs> =
    { [P in K]: { type: P, onChange: (...args: InputArgs[P]) => void } }[K];

The above Input type subsumes your TextInput, NumberInput, and FormControl types, as shown:

type TextInput = Input<"TextInput">;
type NumberInput = Input<"NumberInput">;
type FormControl = Input;

And now you can write withLogger() this way:

export const withLogger = <K extends keyof InputArgs>(formControl: Input<K>): Input<K> => ({
    ...formControl,
    onChange: (...args: InputArgs[K]) => {
        console.log(formControl.type, 'onChange', args);
        return formControl.onChange(...args); // okay
    },
});

This works because onChange for an Input<K> is explicitly represented as being of type (...args: InputArgs[K]) => void, and you can therefore pass it an argument list of type InputArgs[K]. The function and its arguments stay generic and nothing is widened to an unusable union.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360