Search code examples
typescriptmapped-types

Inferring generic 'this' type


Following on from this question, I am now trying to create functions with an explicit this parameter of a type extending interface IModel:

// interface for Model class

interface IModel {
    state: {}
}

// based on answer to this question https://stackoverflow.com/q/59895071/374328
// now functions will be bound to model instance, so need to specify 'this' parameter as Model

type SingleArgFunction<Model extends IModel, A> = (this: Model, x: A) => A;
type ArrayedReturnFunction<Model extends IModel, A> = (this: Model, x: A) => A[];

type SingleArgFunctionObject<Model extends IModel, AS extends object> = {
    [K in keyof AS]: SingleArgFunction<Model, AS[K]>
}

type ArrayedReturnFunctionObject<Model extends IModel, AS extends object> = {
    [K in keyof AS]: ArrayedReturnFunction<Model, AS[K]>
}

function makeArrayed<Model extends IModel, A>(f: SingleArgFunction<Model, A>): ArrayedReturnFunction<Model, A> {
    return function (x) {
        return [f.call(this, x)];
    }
}

function makeArrayedAll<Model extends IModel, AS extends object>(
    fs: SingleArgFunctionObject<Model, AS>
): ArrayedReturnFunctionObject<Model, AS> {
    const result = {} as ArrayedReturnFunctionObject<Model, AS>;

    (Object.keys(fs) as (keyof AS)[]).forEach(function<K extends keyof AS>(key: K) {
        result[key] = makeArrayed(fs[key]);
    })
    return result;
}

An example model and type definitions:

interface MyModel extends IModel {
    state: {
        x: number;
    }
}

interface SingleArgFunctions {
    foo: SingleArgFunction<MyModel, number>
}

interface ArrayedReturnFunctions {
    foo: ArrayedReturnFunction<MyModel, number>;
}

Creating an object of ArrayedReturnFunctions directly is ok, with this being inferred as type MyModel:

const arrayedReturnFunctions1: ArrayedReturnFunctions = {
    foo(x) {
        return [x + this.state.x]; // ok
    }
}

Alternatively, creating the object by applying makeArrayed to a single function is also ok:

const arrayedReturnFunctions2: ArrayedReturnFunctions = {
    foo: makeArrayed(function (x) {
        return x + this.state.x; // ok
    })
}

However, using makeArrayedAll does not work - this is inferred as type IModel:

const arrayedReturnFunctions3: ArrayedReturnFunctions = makeArrayedAll({
    foo(x) {
        return x + this.state.x; // error - property x does not exist on type {}
    }
})

Even creating an object of type SingleArgFunctions and then passing that to makeArrayedAll does not work:

const singleArgFunctions: SingleArgFunctions = {
    foo(x) {
        return this.state.x + x;
    }
}

const arrayedReturnFunctions4 = makeArrayedAll(singleArgFunctions); // error - IModel is not assignable to type MyModel 

Why is the Model type not inferred as MyModel when using makeArrayedAll?

Playground


Solution

  • To me this looks like you're hoping the type of the variable arrayedReturnFunctions to which you're assigning the output of makeArrayedAll() will provide enough contextual typing for the input of makeArrayedAll() so that the input methods' this context will be inferred... but it's not happening. I'm not exactly surprised that contextual inference would fail to happen in this scenario but I don't have a great answer about exactly why or how to force it to happen.

    Right now my only suggestion is to note that type inference tends to work better in the "forward" direction; that is, a generic function's type parameters are easier to infer from the input to that function and not from the expected output type of the function. And if that fails, you could always manually specify the generic type parameter.

    Here's one way to manually specify the model type parameter and let the compiler infer the rest, using currying:

    const makeArrayedAllFor = <M extends IModel>() => <AS extends object>(
      fs: SingleArgFunctionObject<M, AS>) => makeArrayedAll(fs);
    
    const arrayedReturnFunctions3: ArrayedReturnFunctions = makeArrayedAllFor<MyModel>()({
        foo(x) {
            return x + this.state.x;
        }
    })
    
    const arrayedReturnFunctions4 = makeArrayedAllFor<MyModel>()(singleArgFunctions);
    

    This works now, although it's cumbersome. That's the best I can think of at the moment; maybe someone has other ideas? Oh well, good luck!

    playground link