O padrão Typescript para funções genéricas abstratas parece estar quebrado
Eu tenho um padrão assim que usei MUITO em códigos que escrevi meses (até anos) atrás usando versões mais antigas do compilador TypeScript, talvez 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 };
}
};
Na época em que escrevi este código, o Typescript não reclamava nada e lidava com isso como você esperaria.
Mas agora, o TypeScript (versão 2.8.1) reclama:
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'.
Se o TypeScript está correto em marcar isso como um erro, qual seria a maneira correta de realizar a mesma coisa?
Muito obrigado.
Resposta #1
Acho que você provavelmente não quer realmente que myFunc seja genérica. A seguinte assinatura
abstract class MyAbstractClass {
protected abstract myFunc<T extends IBase>(arg: string): T;
};
significa que uma MyAbstractClass tem um método myFunc() que recebe um input string e retorna qualquer subtipo de IBase que o chamador especificar. Mas você quer que ele retorne um subtipo de IBase que o implementador especifique. Se myFunc não fosse protected, você poderia ver o problema rapidamente como
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
Onde você chama a mesma função com o mesmo argumento três vezes, e em tempo de execução eles teriam o mesmo resultado, mas aparentemente o TypeScript acha que os resultados são de tipos diferentes. Este é um padrão estranho e não é o que você pretende transmitir de qualquer forma.
E eu não tenho certeza exatamente de quando isso passou a ser aplicado, mas você não deveria permitir substituir uma função genérica por uma não-genérica, a menos que a função não-genérica realmente seja um subtipo da genérica. Mas, como T pode ser qualquer subtipo de IBase especificado pelo chamador, os únicos tipos não-genéricos que são definitivamente atribuíveis a todo T possível seriam never e any. E eu acho que você não quer retornar esses.
Parece que você só quer que myFunc retorne um IBase ou algum subtipo dele especificado pela implementação da subclasse. Se for assim, você pode fazer isso sem generics:
abstract class MyAbstractClass {
protected abstract myFunc(arg: string): IBase;
};
Isto funcionará para você. A subclasse
class MyClass extends MyAbstractClass {
protected myFunc(arg: string): IExtends {
return { key2: arg };
}
};
é compatível com MyAbstractClass porque os tipos de retorno dos métodos são covariantes, o que significa que se Y é um subtipo de X, então um método de Y pode retornar um subtipo do método análogo de X.
Isso seria diferente se você precisasse que o parâmetro do método fosse mais restrito na subclasse, porque os parâmetros de método são contravariantes. No caso em que T fosse o tipo de arg e não o tipo de retorno, o acima não deveria funcionar... mas funciona porque os parâmetros de método ainda são considerados bivariantes. Mas esse não é o seu caso, eu acho. (Atualização: vejo que é o seu caso. Os parâmetros de método são bivariantes por essa razão, aparentemente, então experimente. Pessoalmente, eu não gosto de bivariança e existem soluções alternativas mais "corretas", mas provavelmente elas dão mais complicação do que valem a pena.)
Espero que isso ajude; boa sorte.
Resposta #2
Muitos agradecimentos a jcalz por me iniciar em um caminho de pesquisa, levando a uma resposta lamentável, mas viável
Ainda estou perplexo quanto ao motivo pelo qual o TypeScript fez essa mudança, porque não vejo problema na forma original de descrever isso.
A resposta é promover os genéricos para o nível da classe em vez de deixá-los nos métodos. Isso tem prós e contras, na minha opinião mais contras do que prós. Os prós são que o corpo da classe abstrata fica, talvez, mais limpo e fácil de ler. Os contras são que, se você tiver muitos tipos diferentes que precisam ser tratados dentro da mesma classe, essa própria classe precisa declarar todos esses tipos na declaração da classe e cada classe derivada também precisa fazer isso, o que significa que isso é mais difícil de ler, na minha opinião. Mas vai funcionar.
Aqui está a resposta completa como eu a vejo, e eu adoraria que qualquer pessoa (incluindo jcalz!) contribuísse com mais pensamentos/aperfeiçoamentos:
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 });
};
};
