TypeScript infiere incorrectamente el parámetro genérico
Tengo una función de TypeScript con un parámetro genérico:
function processNodes<N>(nodes: Nodes<N>)
Hay 2 tipos - 'Nodes' y 'NodesImpl'. NodesImpl<N> efectivamente extiende Nodes<N>.
Estoy llamando a la función 'processNodes' de la siguiente manera:
const nodes: NodesImpl<string> = null as any
const r = processNodes(event.currentTarget, nodes)
Esto falla con el error: TS2345: Argument of type 'NodesImpl<string>' no es asignable al parámetro of type 'Nodes<string | undefined>'. De alguna manera, TypeScript infiere el parámetro genérico N de 'processNodes' como string | undefined en lugar de solo string, lo que hace que toda la línea falle. Una solución es especificar manualmente el tipo: processNodes<string>(nodes).
Sin embargo, la inferencia de tipos falla de una manera muy peculiar, requiriendo una configuración muy compleja para provocar el error, por lo que parece que no puedo resolverlo. Este es un ejemplo mínimo reproducible:
export interface FunctionComponent<P> { (props: P): null }
// Changing any of the 2 types to non-array (removing any '[]'): no more error
interface NodesFunctions<T> { doSomething(node: Node<T>[] | NodeData<T>[]): void }
interface Node<T> {
data: T // if type is string - no more error
Component: FunctionComponent<T> // if type is literally anything else - no more error
}
type NodeData<T> = Partial<Node<T>>
// How does this cause the error?
type Nodes<N> = Node<N>[] & NodesFunctions<N>
type Nodes2<N> = Node<N>[] & NodesFunctions<N>
function processNodes<N>(nodes: Nodes<N>) {}
function f() {
// Replacing with 'Nodes<string>' works
const nodes: Nodes2<string> = null as any
// Error on this line:
processNodes(nodes)
}
Enlace del parque
'Nodes' y 'Nodes2' son idénticos, así que, como TypeScript es un lenguaje de tipos estructurales, no debería importar cuál uses, pero aquí sí importa.
También es interesante notar que al cambiar casi cualquier línea de todo el archivo, el error desaparece, tal como sugieren los comentarios en el ejemplo.
¿Por qué está fallando la inferencia de tipos en el ejemplo proporcionado al usar 'Nodes2' en lugar de 'Nodes', y por qué? Esto parece un problema con el compilador de TypeScript en sí.
Respuesta #1
La inferencia de tipos de TypeScript es efectivamente un conjunto de reglas heurísticas que funcionan bien en una amplia gama de situaciones, pero no pretende ser perfecta o ideal, y a veces se comportará de maneras que son aparentemente inconsistentes o inadecuadas para ciertos casos de uso.
Existen algunos sistemas de tipos que tienen un algoritmo de inferencia bien definido que siempre funciona (para algunas definiciones de "siempre" y "funciona"). Por ejemplo, el sistema de tipos Hindley-Milner es la base de los tipos del lenguaje de programación Haskell, y Haskell tiene un algoritmo de inferencia de tipos robusto y "correcto". Sin embargo, TypeScript no utiliza este sistema. Hay una discusión en microsoft/TypeScript#30134 sobre la posible implementación de un algoritmo de unificación completo para la inferencia. Es poco probable que ocurra ya que sería un cambio masivo en el compilador, y según un comentario en microsoft/TypeScript#17520 del arquitecto principal del equipo de TS, el algoritmo actual
difiere de la inferencia de tipos basada en unificación implementada por algunos lenguajes de programación funcionales, pero tiene la ventaja distintiva de poder realizar inferencias parciales en código incompleto, lo cual es enormemente beneficioso para la autocompletación de sentencias en los IDE.
Así que, para bien o para mal, la inferencia de tipos de TypeScript no es ideal, y no siempre va a ser esclarecedor profundizar en un caso límite particular para ver por qué ocurre algo indeseable, porque es probable que encuentres una interacción de un conjunto de reglas heurísticas que tienen sentido la mayor parte del tiempo.
En particular, la cuestión de por qué se pueden obtener diferentes resultados de inferencia de tipos al reemplazar un tipo por otro estructuralmente idéntico puede responderse señalando que las comparaciones estructurales pueden ser muy lentas y el compilador intentará evitar hacer eso si es posible. Por ejemplo, vea este comentario en el PR no fusionado microsoft/TypeScript#48080.
Si tienes el tipo F<T> y estás tratando de hacer que coincida con F<infer U>, el compilador puede tomar el atajo de inferir U como T sin siquiera necesitar evaluar o consultar la definición de F. Esto significa que el compilador tiende a tratar F como nominalmente tipado para la verificación, resultando en la extraña situación donde incluso los parámetros de tipo no utilizados pueden ser inferidos:
interface Foo<T> { } // don't do this
type X = Foo<string> extends Foo<infer T> ? T : never;
// type X = string
type Y = Foo<number> extends Foo<infer T> ? T : never;
// type Y = number
Si TS siempre recurriera a una comparación estructural completa, eso nunca sucedería. Y dado que a veces sí realiza comparaciones estructurales, es una mala idea tener un parámetro de tipo sin usar, como se describe en esta entrada de preguntas frecuentes.
Por otro lado, si tienes el tipo F<T> y estás intentando hacer coincidirlo con G<infer U>, el compilador necesitará empezar a consultar la definición de F y G para continuar. Incluso si resulta que F y G son estructuralmente idénticos, el compilador podría nunca ser capaz de tomar el atajo de simplemente usar F en lugar de G:
interface Bar<T> { }
type Z = Foo<string> extends Bar<infer T> ? T : never;
// type Z = unknown // not string
En tu código, la siguiente llamada puede tomar el atajo de ignorar la definición de Nodes y simplemente hacer coincidir con N, porque el tipo de nodes1 y el tipo del parámetro nodes de processNodes() están ambos escritos en términos de Nodes:
declare function processNodes<N>(nodes: Nodes<N>): void;
declare const nodes1: Nodes<string>;
processNodes(nodes); // infers string
pero si cambias el tipo de nodes1 a Nodes2<string>, el compilador no tiene otra opción que empezar a examinar las definiciones de Nodes y Nodes2, y aunque sean estructuralmente idénticas, el compilador pierde la oportunidad de tomar el atajo, y obtienes un comportamiento de inferencia diferente:
declare const nodes2: Nodes2<string>;
processNodes(nodes2); // does not infer string
Ocasionalmente hay problemas en GitHub donde esto surge, como microsoft/TypeScript#48116, pero no es un problema fácil de resolver.
Enlace de Playground al código
