Una función pide un timeout en milisegundos. Pasas 5000. Más tarde, otra función pide un timeout en segundos. Pasas 5. En algún punto intermedio, llamas a setTimeout(duration, callback) y no pasa nada durante una hora y veintitrés minutos.
TypeScript no te salva aquí. 5000 y 5 son ambos number. El compiler no ve diferencia entre una distancia en metros y una distancia en pies, una temperatura en Celsius y una temperatura en Fahrenheit, un timestamp y una duración. Tu suite de pruebas probablemente tampoco lo detecta, porque las matemáticas son correctas. Las unidades simplemente están mal.
La solución es dejar de tratar las unidades como documentación y empezar a tratarlas como tipos.
Por qué number es el tipo incorrecto para cantidades físicas
TypeScript usa tipado estructural. Dos objetos son compatibles si sus formas coinciden. Esto suele ser una característica, pero para primitivos como number significa que todos los números son intercambiables. Un number es un number es un number.
Los checks en tiempo de ejecución pueden detectar errores de unidades, pero son costosos de mantener y fáciles de omitir. Necesitarías validar cada argumento de función, cada respuesta de API, cada constante definida en otro archivo. En la práctica, nadie hace esto. Los checks se convierten en comentarios, y los comentarios mienten.
La alternativa es codificar la unidad directamente en el tipo. En tiempo de compilación, Seconds y Milliseconds se convierten en tipos incompatibles. Multiplicas Meters por Meters y obtienes SquareMeters. Sumas Miles a Kilometers y el compiler se niega. En tiempo de ejecución, el valor sigue siendo solo un número. No hay objeto container, no hay validación en tiempo de ejecución, no hay coste de rendimiento. Esto es una abstracción de coste cero.
Cómo los tipos fantasma convierten un número en una unidad marcada
TypeScript no soporta tipado nominal para primitivos, pero soporta tipos de intersección y símbolos únicos. Puedes marcar un primitivo para que dos marcas sean incompatibles incluso cuando el valor subyacente es idéntico.
Este es el patrón:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
La propiedad __brand no existe en tiempo de ejecución. Es un tipo fantasma. Existe solo en el sistema de tipos. Pero es suficiente para hacer que Meters y Kilometers sean mutuamente incompatibles.
No puedes asignar accidentalmente un number simple a un tipo marcado. Este es el punto. Debes construir uno explícitamente, lo que te obliga a declarar la unidad.
Un sistema de unidades funcional en TypeScript
Aquí tienes una implementación mínima pero completa que maneja construcción, conversión y aritmética.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Uso:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
El error aparece donde se introduce el bug, no donde el valor se usa finalmente. No necesitas rastrear velocity a través de tres archivos para descubrir que alguien pasó milisegundos a un parámetro de segundos.
Derivando unidades compuestas de unidades base
El patrón escala a unidades compuestas. En lugar de escribir MetersPerSecond a mano, puedes derivarlo de tipos base usando un constructor genérico.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
En la práctica, puede que no necesites análisis dimensional completo. La mayoría de equipos alcanzan rendimientos decrecientes después de una docena o así de tipos de unidad. El objetivo no es modelar física. El objetivo es eliminar la categoría de bug más costosa: aquella donde las matemáticas funcionan pero las unidades no.
Los trade-offs que deberías conocer
Los tipos marcados no son gratuitos. Cuestan ergonomía.
Cada literal debe envolverse en un constructor. setTimeout(callback, 5000) se convierte en setTimeout(callback, milliseconds(5000)). Eso es más escritura. Si tu equipo es inconsistente con los constructores, terminas con casts inseguros esparcidos por el código. El patrón solo funciona si todos lo usan.
La inferencia de tipos también se vuelve ruidosa. Los métodos de array y las funciones genéricas pueden exponer la marca en mensajes de error. Un number[] simple es más fácil de leer que (number & { readonly __brand: "Milliseconds" })[]. Puede que necesites alias de tipo para mantener las firmas legibles.
La serialización es otro punto de fricción. JSON no tiene concepto de tipos marcados. Cuando envías un valor Meters por la red, llega como un number simple al otro lado. Debes reconstruir la marca en el límite. Este es el lugar correcto para hacerlo, pero es código extra.
La mayor limitación es que esta es una técnica solo de TypeScript. Si tu sistema incluye servicios Python, microservicios Go, o consumers JavaScript planos, las marcas desaparecen en el límite del lenguaje. Todavía necesitas validación en tiempo de ejecución en los bordes del sistema. Los tipos marcados protegen el código TypeScript interno. No reemplazan esquemas para datos externos.
Cómo introducir esto sin molestar a tu equipo
No marques cada número en tu código. Empieza con los parámetros que han causado incidents reales.
- Identifica los últimos tres bugs relacionados con unidades en producción. Busca milisegundos vs segundos, monedas en diferentes denominaciones, latitud vs longitud, o coordenadas de pantalla vs coordenadas de documento.
- Marca esos tipos específicos. Añade constructores y funciones de conversión.
- Actualiza las funciones donde se usan esos valores. Deja que el compiler te guíe.
- Añade una regla de lint que prohíba
numbercrudo para esos parámetros.
No marques contadores de bucle, indexes de array o porcentajes. Esos son adimensionales. Añadir una marca ahí es ceremonia sin valor.
Si estás trabajando en un lenguaje con tipado nominal más fuerte, tienes mejores opciones. Los usuarios de Rust deberían mirar el crate uom. F# y OCaml tienen unidades de medida integradas en el compiler. El sistema de tipos estructural de TypeScript hace que esto sea un workaround, no una característica de primera clase. Pero el workaround es suficientemente bueno para detectar bugs reales.
Preguntas frecuentes
¿Añade esto alguna sobrecarga en tiempo de ejecución?
No. La marca es una construcción solo en tiempo de compilación. Después de la compilación, meters(100) es solo el número 100. No hay objeto container, no hay propiedad extra, no hay check en tiempo de ejecución.
¿Qué pasa con la multiplicación y la división?
Necesitas funciones explícitas u operadores sobrecargados. TypeScript no soporta sobrecarga de operadores, así que distance / time debe pasar por una función per() o speed(). Esto es verboso, pero también es la razón por la que el bug se detecta.
¿Puedo usar esto con bibliotecas de terceros?
Solo si la biblioteca acepta tu tipo marcado. Si setTimeout espera number, puedes pasar Milliseconds porque la marca es una intersección con number. Lo contrario no es cierto. Si una biblioteca devuelve number, debes marcarlo explícitamente antes de usarlo como un tipo de unidad.
¿Cómo manejo unidades fraccionarias como HalfSeconds?
Usa la unidad base y un constructor. halfSeconds(1) devuelve Milliseconds(500). No crees una marca para cada subdivisión. Mantén el número de unidades base pequeño.
Deja de escribir nombres de unidades en nombres de variables
Llamar a una variable timeoutInMs es documentación. La documentación se desvía. Llamarla timeout: Milliseconds es un tipo. Los tipos se aplican.
La próxima vez que depures un problema y te des cuenta de que el timeout estaba desviado por un factor de mil, pregúntate si el nombre de la variable era suficiente. No lo era. Codifica la unidad en el tipo, deja que el compiler haga el trabajo, y deja de confiar en que recordarás si esta función en particular quiere segundos o milisegundos.