Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript pattern for abstract generic functions seems to be broken

I have a pattern like this that I used A LOT in code I wrote months (even years) ago using older versions of the typescript compiler, maybe 1.8 - 2.2:

interface IBase { };

interface IExtends extends IBase {
    key2: string;
};

abstract class MyAbstractClass {
    protected abstract myFunc<T extends IBase>(arg: string): T;
};

class MyClass extends MyAbstractClass {
    protected myFunc(arg: string): IExtends {
        return { key2: arg };
    }
};

Back when I wrote this code, Typescript did not complain at all, and handled this as you would expect.

But now, typescript (version 2.8.1) complains:

src/errorInvestigation.ts(12,15): error TS2416: Property 'myFunc' in type 'MyClass' is not assignable to the same property in base type 'MyAbstractClass'.
  Type '(arg: string) => IExtends' is not assignable to type '<T extends IBase>(arg: string) => T'.
    Type 'IExtends' is not assignable to type 'T'.

If typescript is correct in marking this as an error, then what would be the correct way to accomplish the same thing?

Many thanks.

like image 383
Stephan G Avatar asked Oct 18 '25 13:10

Stephan G


2 Answers

I think you probably don't really mean for myFunc to be generic. The following signature

abstract class MyAbstractClass {
    protected abstract myFunc<T extends IBase>(arg: string): T;
};

means that a MyAbstractClass has a myFunc() method which takes a string input and returns any subtype of IBase that the caller specifies. But you want it to return a subtype of IBase that the implementer specifies. If myFunc weren't protected you could see the problem quickly like

declare const myAbstractInstance: MyAbstractClass;
let iBase = myAbstractInstance.myFunc<IBase>('hey'); // IBase
let iExtends = myAbstractInstance.myFunc<IExtends>('hey'); // IExtends?!
let iOther = myAbstractInstance.myFunc<IBase & {foo: string}>('hey'); // What?!
iOther = iExtends; // error, not assignable

Where you call the same function with the same argument three times, and at runtime they would have the same output, but apparently TypeScript thinks the outputs are of different types. This is a strange pattern and not what you are intending to convey anyway.

And I'm not sure exactly when it got enforced, but you shouldn't be allowed to override a generic function with a non-generic one unless the non-generic one really is a subtype of the generic one. But since T can be any subtype of IBase specified by the caller, the only non-generic types that are definitely assignable to every possible T would be never and any. And you don't want to return those, I think.


It sounds like you just want myFunc to return an IBase or some subtype of it specified by the subclass implementation. If so, then you can do this without generics at all:

abstract class MyAbstractClass {
  protected abstract myFunc(arg: string): IBase;
};

This will work for you. The subclass

class MyClass extends MyAbstractClass {
    protected myFunc(arg: string): IExtends {
        return { key2: arg };
    }
};

is compatible with MyAbstractClass because method return types are covariant, meaning that if Y is a subtype of X, then a method of Y is allowed to return a subtype of the analogous method of X.

This would be different if you needed the method parameter to be narrower in the subclass, because method parameters are contravariant. In the case that T was the type of arg and not the return type, the above shouldn't work... but it does happen to work because method parameters are still considered bivariant. But that is not your use case, I think. (Update: I see that it is your use case. Method parameters are bivariant for this reason apparently, so try it out. Personally I don't like bivariance and there are more "correct" workarounds but they are probably more trouble than they are worth.)

Hope that helps; good luck.

like image 114
jcalz Avatar answered Oct 20 '25 05:10

jcalz


Many thanks to jcalz for starting me down a research path, leading to a regrettable but workable answer

I am still baffled as to why typescript made this change, because I see no problem with the original way of describing this.

The answer is to promote the generics to the class level rather than leaving them on the methods. This has pros and cons, in my opinion more cons than pros. The pros are that the body of the abstract class is perhaps cleaner and easier to read. The cons are that if you have many different types that need to be handled within the same class, that class itself needs to declare all those types on the class declaration and every derived class needs to do that too, which means this is harder to read I think. But it will do the job.

Here is the complete answer as I see it, and I would love anyone (including jcalz!) to chime in with further thoughts/refinements:

interface IArgBase {
    baseArgKey: string;
};

interface IReturnBase {
    baseReturnKey: string;
};

interface IPromiseBase {
    basePromiseKey: string;
};

interface IArgExtends extends IArgBase {
    extendedArgKey: string;
};

interface IReturnExtends extends IReturnBase {
    extendedReturnKey: string;
};

interface IPromiseExtends extends IPromiseBase {
    extendedPromiseKey: string;
};

abstract class MyAbstractClass<TArg extends IArgBase, TReturn extends IReturnBase, TPromise extends IPromiseBase>{
    protected abstract myFunc(arg: TArg): TReturn;
    protected abstract myAsyncFunc(arg: TArg): Promise<TPromise>;
};

class MyClass extends MyAbstractClass<IArgExtends, IReturnExtends, IPromiseExtends> {
    protected myFunc(arg: IArgExtends): IReturnExtends {
        return { baseReturnKey: arg.baseArgKey, extendedReturnKey: arg.extendedArgKey };
    };
    protected myAsyncFunc(arg: IArgExtends): Promise<IPromiseExtends> {
        return Promise.resolve({ basePromiseKey: arg.baseArgKey, extendedPromiseKey: arg.extendedArgKey });
    };
};
like image 29
Stephan G Avatar answered Oct 20 '25 05:10

Stephan G