typescript

Constrained callback arguments


I have this:

const any = (any?: any): any => any;

export type Fn = (cb: (...args: (string | number)[]) => any) => any;

const Fn: Fn = any();

const t1 = Fn((x: unknown) => x); // Allowed
const t2 = Fn(<X>(x: X) => x); // Allowed

How can i disallow unknown parameters inside the callback?

I have tried:

None of these have worked for me so far. So, how do I avoid callbacks without constrained arguments.

Here is what should be allowed:

const t3 = Fn((a: string, b: number) => {});
const t4 = Fn(() => {});
const t5 = Fn((x: string | number, y: string) => {});
const t6 = Fn(<X extends number>(x: X) => x);
const t7 = Fn(<X extends string, Y extends string>(x: X, y: Y) => x + y);

Currently most of these don't work either... How can I solve this issue?

Playground


Solution

  • TypeScript really isn't intended to help enforce constraints like this. It allows you to assign a value of type (x: X) => void to a variable of type (y: Y) => void whenever Y extends X, because if a function accepts all X values, then of course it will also accept Y values, because Y extends X. It's a natural consequence of the contravariance of function types in their parameter types; see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript.

    It sounds like you want TypeScript to act as a linter for your code base, preventing developers from mentioning types you don't like in certain places. To say that you want to prevent passing in an argument of type (x: unknown) => void to a parameter of type (x: string | number) => void is to fight against the TypeScript type system. Such a restriction is not a matter of type safety, because it doesn't seem to have anything to do with avoiding runtime errors due to type mismatches. You can certainly fight the TypeScript type system, but any solution inside TypeScript is going to be complicated and probably fragile.

    Here's one possible approach:

    export type Fn = <T extends Function>(
        cb: T extends (T extends (...args: infer A) => void ?
            A[number] extends (string | number) ? unknown : never
            : never) ? T : never
    ) => any;
    

    Now Fn is generic in the type F of the cb parameter. When you call Fn(cb), TypeScript will infer T to be the type of cb, and then it will check it against the complicated conditional type above. If it matches T extends (...args: infer A) => void ? A[number] extends (string | number) ? unknown : never : never then the parameter type evaluates to T, and since T extends T then it is acceptable. Otherwise it evaluates to never, and since T extends never cannot be true for any actual cb you pass in, then it is unacceptable.

    So what's T extends (...args: infer A) => void ? A[number] extends (string | number) ? unknown : never : never? Well, we're using conditional type inference to extract the tuple type of T's parameters into the type parameter A, and then checking if the union of the element types of A (that is, A[number]) is assignable to string | number. If it is, then we know that all the parameters of T are subtypes of string | number, and the overall type evaluates to unknown. If not, then at least one parameter of T is not a subtype of string | number, and the overall type evaluates to never. So that means if the parameters are what you want, the type of the cb parameter is T extends unknown ? T : never, or just T, which accepts the argument you passed in. And if any parameter isn't want you want, the type of the cb parameter is T extends never ? T : never, or just never, which rejects the argument you passed in.

    I did say it was complicated.

    Let's test it out:

    const t1 = Fn((x: unknown) => x); // disallowed
    const t2 = Fn(<X,>(x: X) => x); // disallowed
    Fn((x: boolean) => x); // disallowed
    Fn((x: string | boolean) => x); // disallowed
    
    const t3 = Fn((a: string, b: number) => { });
    const t4 = Fn(() => { });
    const t5 = Fn((x: string | number, y: string) => { });
    const t6 = Fn(<X extends number>(x: X) => x);
    const t7 = Fn(<X extends string, Y extends string>(x: X, y: Y) => x + y);
    

    This is all behaving as you desired. The first four calls above are rejected, since the callbacks' parameter types involve types outside the string or number range... even though for t1 and t2, there would almost certainly be no runtime errors caused by it. The next five calls are all accepted, since the callbacks' parameter types are all within the string and number range.

    Playground link to code