typescript - Forma segura de usar un tipo unión de funciones con claves reasignadas
Di que tengo un tipo de unión Action discriminado en un solo campo @type, así:
interface Sum {
'@type': 'sum'
a: number
b: number
}
interface Square {
'@type': 'square'
n: number
}
type Action = Sum | Square
Luego escribo un objeto que contiene un método para cada Action usando reasignación de claves:
const handlers: {[A in Action as A['@type']]: (action: A) => number} = {
sum: ({a, b}) => a + b,
square: ({n}) => n ** 2
}
¿Hay una manera segura de llamar a uno de los manejadores pasando el Action como argumento? Este código debería ser seguro:
const runAction = (action: Action) => {
return handlers[action['@type']](action) // <-- Not valid
}
Pero TypeScript dice:
Argument of type 'Action' is not assignable to parameter of type 'never'.
The intersection 'Sum & Square' was reduced to 'never' because property ''@type'' has conflicting types in some constituents.
Type 'Sum' is not assignable to type 'never'.
¿Cuál sería una buena manera de escribir la función anterior de manera segura sin convertir a un tipo menos estricto?
Enlace del parque
Respuesta #1
El problema principal aquí es que el tipo de handlers[action['@type']] y el tipo de action son ambos tipos unión (es decir, ((action: Sum) => number) | ((action: Square) => number) y Sum | Square), pero estos valores están correlacionados entre sí de una manera que no se captura con esos tipos unión no correlacionados/independientes. El compilador no entiende que, por ejemplo, nunca pasarás un Square a (action: Sum) => number. Este problema general es el tema de microsoft/TypeScript#30581.
Tenga en cuenta que el compilador no distribuye su análisis de flujo de control a través de las uniones. Específicamente, al analizar
handlers[action['@type']](action)
el compilador no hace lo que podría hacer un ser humano, y decir "ok, action es ya sea un Sum o un Square. Si es un Sum, entonces action['@type'] es "sum" y por lo tanto handlers[action['@type']] es un (action: Sum) => number y entonces handlers[action['@type']](action) es una llamada válida y resulta en un number. Si es un Square, entonces action['@type'] es "square" y por lo tanto handlers[action['@type']] es un (action: Square) => number y entonces handlers[action['@type']](action) es una llamada válida y resulta en un number. En ambos casos obtenemos un number, por lo que el resultado es un number. Tal análisis requeriría que el compilador revisara la línea una vez por cada miembro de la unión Action. En esta situación específica, eso es solo dos veces, pero las uniones pueden ser más grandes que dos, y el número de casos a analizar crece exponencialmente con el número de expresiones de tipos de unión independientes. Simplemente no es factible que el compilador analice potencialmente una línea de código un gran número de veces. La propuesta rechazada microsoft/TypeScript#25051 era una solicitud para permitir que los desarrolladores pidieran dicho análisis adicional según fuera necesario. Pero, como dije, fue rechazada. Así que no hay una solución simple de la forma "simplemente agrega as if switch(action) al final de la línea".
En las versiones de TypeScript anteriores a la 4.6, tus opciones aquí serían o renunciar a la seguridad de tipos utilizando afirmaciones de tipo o el equivalente:
const runAction = (action: Action) => {
return (handlers[action['@type']] as (action: Action) => number)(action);
}
o escribir código redundante para obligar al compilador a realizar el tipo de análisis de múltiples pasadas que mencioné antes:
const runAction = (action: Action) => {
return action['@type'] === "square" ?
handlers[action['@type']](action) :
handlers[action['@type']](action);
}
Ninguno de estos es excelente. El primero es mi recomendación general si te importa más la conveniencia que la seguridad. El segundo solía ser la única manera de obtener seguridad de tipos, pero no se escala bien.
A partir de TypeScript 4.6, se lanzará microsoft/TypeScript#47109, que proporciona una manera de refactorizar código con uniones correlacionadas a una forma que el compilador puede verificar como segura. Esto implica el uso de genéricos con los llamados "tipos de objeto distributivos". Así es como podría verse para tu ejemplo:
type ActionMap = { [A in Sum | Square as A['@type']]: A }
type Action<K extends keyof ActionMap = keyof ActionMap> =
{ [P in K]: ActionMap[P] & { "@type": P } }[K]
const runAction = <K extends keyof ActionMap>(action: Action<K>) => {
return handlers[action['@type']](action)
}
Primero configuramos un tipo ActionMap que es un mapeo simple desde A['@type'] como claves hacia el tipo de acción correspondiente. Se ve como {sum: Sum; square: Square}.
Luego definimos Action no como una unión simple, sino como un tipo genérico Action<K> que toma un subtipo de "sum" | "square" y produce el tipo de acción correspondiente. Este es el "tipo de objeto distributivo" donde Action<K> será una unión si K es una unión. Hay un valor predeterminado, así que simplemente escribir Action produce el mismo tipo de unión de tu código. Ten en cuenta que hay un pequeño giro aquí, que es que estamos diciendo explícitamente al compilador que, genéricamente, para cada miembro de la unión P en K, Action<K> tendrá una propiedad "@type" de tipo P. Esta es información redundante, pero ayuda al compilador a ver mejor la correlación.
Finalmente, runAction() ahora es una función genérica en K, donde action es del tipo Action<K>. Y ahora, handlers[action['@type']](action) se considera seguro en cuanto a tipos sin tener que ser escrito varias veces. La desventaja es que la refactorización no es obvia, pero esto es lo mejor que se puede lograr en términos de seguridad de tipos y escalabilidad.
Enlace de Playground al código
