skip to Main Content

We’ve defined a base class that will be subclassed many times. We want to define an exec method on the base class that accepts the name and arguments of a method on a derived class, and then calls it.

However, we’ve run into an issue with the typing of the arguments of the derived class’s method. Here’s a snippet that demonstrates the error:

class BaseClass {
    exec<
        Self extends Record<MethodKey, (...args: any) => void>,
        MethodKey extends keyof Self,
        Method extends Self[MethodKey],
    >(
        this: Self,
        methodKey: MethodKey,
        ...args: Parameters<Method>
    ) {
        this[methodKey](...args);
    }
}

class MyClass extends BaseClass {   
    constructor() {
        super();
        this.exec(`sayMessage`, `Hello`); // Error: Argument of type '["Hello"]' is not assignable to parameter of type 'Parameters<this["sayMessage"]>'.
    }

    sayMessage(message: string) {
        console.log(message);
    }
}

TS Playground link

Why doesn’t this work, and how do we correctly type the exec method so that it accepts the other method’s arguments?

2

Answers


  1. Chosen as BEST ANSWER

    I don't entirely understand why, but it works when Args is defined prior to the method:

    class BaseClass {
        exec<
            Args extends Array<unknown>,
            Self extends Record<MethodKey, (...args: Args) => void>,
            MethodKey extends keyof Self,
        >(
            this: Self,
            methodKey: MethodKey,
            ...args: Args
        ) {
            this[methodKey](...args);
        }
    }
    
    class MyClass extends BaseClass {
        constructor() {
            super();
            this.exec(`sayMessage`, 32);
        }
    
        sayMessage(message: number) {
            console.log(message);
        }
    }
    

    TS Playground link

    Credit to this chat in the Typescript discord: https://discord.com/channels/508357248330760243/1215722676904132648/1215722676904132648


  2. The main issue here is actually that the type inferred for Self is the polymorphic this type, which is an implicitly generic type constrained to the current class instance type. And since the Parameters utility type is implemented as a conditional type, it means that args is of type Parameters<this["sayMessage"]>, a generic conditional type. The compiler has no idea what values might or might not be assignable to such a type because it tends to just defer evaluation. Until it knows exactly what this is, it doesn’t hazard a guess as to what Parameters<this["sayMessage"]> might be. And so it fails.

    It’s much easier on the compiler if you give it values of the exact type it’s supposed to infer. So instead of args being some complicated thing, have it be its own generic type parameter A, and try to rewrite other things in terms of it. For example:

    class BaseClass {
        exec<
            A extends any[],
            K extends PropertyKey
        >(
            this: Record<K, (...args: NoInfer<A>) => void>,
            methodKey: K,
            ...args: A
        ) {
            this[methodKey](...args);
        }
    }
    

    This is very similar to yours, except now we only have two generic type parameters. There’s A, constrained to a list type, and K, corresponding to your MethodKey, constrained to PropertyKey which is just a utility type equivalent to "keylike" types, string | number | symbol.

    And then for the this parameter we say that it is of type Record<K, (...args: NoInfer<A>) => void>. This is essentially just "something with a property of key K which is a function accepting an arg list of type A" except we don’t really want the compiler to try to infer A from this. It doesn’t work out. So we use the new NoInfer utility type introduced in TypeScript 5.4 to tell the compiler not to try. (There are workarounds for 5.3 and below, but I’m not worried about them).

    And now when you call it, it works. The compiler is happy to see that this is of a type like {sayMessage: (...args: string)=>void}, and it doesn’t need to try to deal with generic conditional types at all:

    class MyClass extends BaseClass {
        constructor() {
            super();
            this.exec(`sayMessage`, "hello"); // okay
        }
    
        sayMessage(message: string) {
            console.log(message);
        }
    }
    

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search