Implementación mínima de una función con tipo de retorno condicional en TypeScript
El manual de TypeScript ofrece el siguiente ejemplo de uso de tipos condicionales en lugar de sobrecarga de funciones:
https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
// ^ = let a: NameLabel
let b = createLabel(2.8);
// ^ = let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42);
// ^ = let c: NameLabel | IdLabel
Sin embargo, los Manuales no proporcionan una implementación real de la función. He intentado implementar la función de la siguiente manera (playground):
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
if (typeof idOrName === 'number') {
return { id: idOrName };
}
return { name: idOrName };
// ^ Type '{ name: T; }' is not assignable to type 'NameOrId<T>'.(2322)
}
lo que resulta en un error de compilador, por lo que aparentemente la estrecha de tipos de TypeScript no puede resolver esto. ¿Qué información adicional necesito proporcionar al compilador para una implementación válida? Muchas gracias de antemano.
Respuesta #1
Esta es actualmente una limitación de TypeScript. Consulte microsoft/TypeScript#33912 para una solicitud de función para mejorar esto.
El compilador no es particularmente hábil al razonar sobre tipos genéricos no especificados, especialmente cuando el tipo es un tipo condicional. Un tipo como NameOrId<T>, donde T no es un tipo específico (como el parámetro de tipo genérico T dentro de la implementación de createLabel()), es más o menos opaco para el compilador. Retrasa la evaluación del tipo hasta que se especifiquen los parámetros de tipo genérico de los que depende. Hasta entonces, realmente no puede verificar que algún tipo específico sea asignable a él.
Así que todo lo que obtienes son estos errores. Por ahora solo hay soluciones alternativas.
Cuando el compilador no puede verificar que alguna expresión sea asignable a algún tipo, pero tú estás seguro de ello, puedes usar aserciones de tipo para simplemente decirle al compilador tu certeza. Las aserciones de tipo te permitirán tratar una expresión x que el compilador ve como tipo X como una expresión del tipo Y (x as Y), siempre que el compilador considere que X y Y están suficientemente relacionados. Cuando no lo cree así, usualmente puedes forzar que esto suceda con una aserción intermedia relacionada con ambos X y Y (como x as any as Y o x as unknown as Y).
Para createLabel() esto podría verse de la siguiente manera:
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
if (typeof idOrName === 'number') return { id: idOrName as number } as NameOrId<T>;
return { name: idOrName as string } as NameOrId<T>;
}
Otra solución alternativa es seguir usando sobrecargas, que permiten la separación entre la firma de llamada de una función (posiblemente varias de estas) y su firma de implementación. El compilador es bastante flexible al verificar la firma de implementación contra las firmas de llamada, lo que tiende a dar al desarrollador una libertad similar a la de las aserciones de tipo, sin tener que escribir as Y por todas partes. Sin embargo, tiene el mismo problema con la seguridad de tipos, por lo que al hacer esto estás esencialmente asumiendo parte de la responsabilidad de verificar la seguridad que normalmente tendría el compilador.
En tu caso, solo tenemos una firma de llamada, una firma de llamada genérica cuyo tipo de retorno es un tipo condicional. Y para la firma de implementación, podemos ampliar T al límite asociado, string | number:
function createLabel<T extends number | string>(idOrName: T): NameOrId<T>;
function createLabel(idOrName: number | string): NameOrId<number | string> {
if (typeof idOrName === 'number') return { id: idOrName };
return { name: idOrName };
}
El compilador está contento porque ve el tipo de retorno de la implementación como IdLabel | NameLabel, que aunque no es verificablemente asignable a NameOrId<T>, es lo suficientemente similar como para que la sobrecarga se considere compatible.
Solo para subrayar el punto: esto es una solución alternativa. El compilador no puede verificar exactamente lo que estás haciendo. Se te impedirá hacer algo totalmente descabellado como
function createLabelBonkers<T extends number | string>(idOrName: T): NameOrId<T> {
return new Date() as NameOrId<T>; // error
// Conversion of type 'Date' to type 'NameOrId<T>' may be a mistake
}
function createLabelBonkers2<T extends number | string>(idOrName: T): NameOrId<T>;
function createLabelBonkers2(idOrName: number | string): NameOrId<number | string> {
return new Date(); // error, not assignable to NameLabel | IdLabel
}
pero si cometes errores donde los tipos involucrados están relacionados con los correctos, el compilador no te advertirá y no puede hacerlo:
function createLabelBad<T extends number | string>(idOrName: T): NameOrId<T> {
return (Math.random() < 0.5 ? { id: 123 } : { name: "abc" }) as NameOrId<T>
}
function createLabelBad2<T extends number | string>(idOrName: T): NameOrId<T>;
function createLabelBad2(idOrName: number | string): NameOrId<number | string> {
return Math.random() < 0.5 ? { id: 123 } : { name: "abc" };
}
No ve la diferencia entre las implementaciones de createLabel() y createLabelBad(). En ninguno de los casos puede detectar si las implementaciones son correctas o no; la responsabilidad de hacer esto recae en ti, así que ten cuidado.
Enlace de Playground al código
