Cómo hacer que la inferencia de tipos funcione para un método genérico en TypeScript
Estoy intentando reescribir algo de código C# en TypeScript sin éxito. El tipo de respuesta no se puede inferir a menos que proporcione la clase base directamente.
class CommandParameter {
readonly value: string = "value";
}
class Command<T> {}
class SubCommand<T> extends Command<T> {}
class Client {
execute<T>(command: Command<T>): T {
return null as T;
}
}
//TS2339: Property 'value' does not exist on type 'unknown'.
const valueError = new Client().execute(new SubCommand<CommandParameter>()).value; //<--error
const valueOk = new Client().execute(new Command<CommandParameter>()).value;
¿Es siquiera posible lograr el comportamiento deseado (hacer que la inferencia funcione para subclases) con Typescript? ¿Quizás existen patrones alternativos similares?
Respuesta #1
El sistema de tipos de TypeScript es en gran medida estructural y no nominal. Los tipos se comparan por sus formas/estructuras y no por sus nombres/declaraciones. Tus clases de ejemplo
class Command<T> {}
class SubCommand<T> extends Command<T> {}
son estructuralmente idénticos entre sí y al tipo de objeto vacío, y estructuralmente independientes de sus parámetros de tipo genérico. Por lo tanto, Command<string> y SubCommand<number> y Command<Date> y SubCommand<boolean> son todos el mismo tipo.
Esto hace que la inferencia se comporte de manera deficiente; a veces el compilador puede de hecho inferir T a partir de algo como Command<T> mediante coincidencia de patrones en las declaraciones, como si tuviera un sistema de tipos nominal. Pero incluso donde eso funciona, es increíblemente frágil. En cuanto el compilador realiza una comparación estructural, entonces no hay manera de inferir T a partir de {}. Este problema con la inferencia se describe en la entrada de la FAQ de TypeScript ¿Por qué no funciona la inferencia de tipos en esta interfaz?: interface Foo<T> { }?
La solución en general es que hagas que cualquier tipo genérico dependa estructuralmente de sus parámetros de tipo genérico. Por ejemplo:
class Command<T> {
x;
constructor(x: T) {
this.x = x;
}
}
class SubCommand<T> extends Command<T> {
}
Ahora un Command<T> tiene una propiedad x del tipo T, y lo mismo ocurre con un SubCommand<T> porque hereda de Command<T>. Así que ahora estos tipos ya no son idénticos a {}, sino a {x: T}. Inferir T a partir de {} no es posible en general, pero inferir T a partir de {x: T} es sencillo. Y ahora todo funciona:
const v1 = new Client().execute(new SubCommand(new CommandParameter())).value;
const v2 = new Client().execute(new Command(new CommandParameter())).value;
Obviamente no siempre es aplicable darle a una clase una propiedad cuyo tipo sea el parámetro de tipo genérico, pero con suerte realmente manejas valores de tipo T en algún lugar de tu clase para que un Command<T> dependa estructuralmente de T de alguna manera. Si no, entonces los genéricos quizá no sean realmente lo que quieres, o podrías tener que simular tipado nominal con propiedades ficticias/fantasma. Pero esto está fuera del alcance de la pregunta tal como se planteó, así que no me desviaré más aquí.
Enlace de Playground al código
