Uma função pede um timeout em milliseconds. Você passa 5000. Mais tarde, outra função pede um timeout em seconds. Você passa 5. Em algum lugar entre elas, você chama setTimeout(duration, callback) e nada acontece por uma hora e vinte e três minutos.
TypeScript não te salva aqui. 5000 e 5 são ambos number. O compiler não vê diferença entre uma distância em meters e uma distância em feet, uma temperatura em Celsius e uma temperatura em Fahrenheit, um timestamp e uma duration. Sua test suite provavelmente também não detecta, porque a matemática está correta. As units estão apenas erradas.
A solução é parar de tratar units como documentação e começar a tratá-las como types.
Por que number é o type errado para grandezas físicas
TypeScript usa structural typing. Dois objetos são compatíveis se suas shapes combinam. Isso geralmente é uma feature, mas para primitives como number significa que todos os numbers são intercambiáveis. Um number é um number é um number.
Runtime checks podem detectar erros de unit, mas são caros de manter e fáceis de pular. Você precisaria validar cada function argument, cada API response, cada constant definida em outro arquivo. Na prática, ninguém faz isso. As checks viram comments, e comments mentem.
A alternativa é codificar a unit diretamente no type. Em tempo de compilação, Seconds e Milliseconds se tornam types incompatíveis. Multiplique Meters por Meters e você obtém SquareMeters. Some Miles a Kilometers e o compiler recusa. Em runtime, o valor ainda é apenas um number. Não há wrapper object, nenhuma runtime validation, nenhum custo de performance. Isso é uma zero-cost abstraction.
Como phantom types transformam um number em uma branded unit
TypeScript não suporta nominal typing para primitives, mas suporta intersection types e unique symbols. Você pode brandar um primitive para que duas brands sejam incompatíveis mesmo quando o valor subjacente é idêntico.
Aqui está o padrão:
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">;
A propriedade __brand não existe em runtime. É um phantom type. Existe apenas no sistema de tipos. Mas é o suficiente para tornar Meters e Kilometers mutuamente incompatíveis.
Você não pode acidentalmente atribuir um number simples a um branded type. Esse é o ponto. Você deve construir um explicitamente, o que te força a declarar a unit.
Um sistema de units funcional em TypeScript
Aqui está uma implementação mínima mas completa que lida com construção, conversão e 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'.
O erro aparece onde o bug é introduzido, não onde o valor é eventualmente usado. Você não precisa rastrear velocity de volta por três arquivos para descobrir que alguém passou milliseconds para um parâmetro de seconds.
Derivando compound units a partir de base units
O padrão escala para compound units. Em vez de escrever MetersPerSecond manualmente, você pode derivá-lo de base types usando um generic constructor.
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>;
}
Na prática, você pode não precisar de full dimensional analysis. A maioria das equipes atinge retornos decrescentes depois de uma dúzia ou mais de unit types. O objetivo não é modelar physics. O objetivo é eliminar a categoria mais cara de bug: aquele onde a matemática funciona, mas as units não.
Os trade-offs que você deve conhecer
Branded types não são gratuitas. Elas custam ergonomia.
Cada literal deve ser envolvido em um constructor. setTimeout(callback, 5000) se torna setTimeout(callback, milliseconds(5000)). Isso é mais digitação. Se sua equipe for inconsistente sobre constructors, você acaba com unsafe casts espalhadas pela codebase. O padrão só funciona se todos usarem.
A type inference também fica barulhenta. Array methods e generic functions podem expor a brand em mensagens de erro. Um number[] simples é mais fácil de ler do que (number & { readonly __brand: "Milliseconds" })[]. Você pode precisar de type aliases para manter as signatures legíveis.
Serialization é outro ponto de atrito. JSON não tem conceito de branded types. Quando você envia um valor Meters over the wire, ele chega como um number simples do outro lado. Você deve reconstruir a brand no boundary. Esse é o lugar certo para fazer isso, mas é código extra.
A maior limitação é que essa é uma técnica exclusiva do TypeScript. Se seu sistema inclui Python services, Go microservices ou plain JavaScript consumers, as brands desaparecem no language boundary. Você ainda precisa de runtime validation nas bordas do sistema. Branded types protegem código TypeScript interno. Elas não substituem schemas para dados externos.
Como introduzir isso sem irritar sua equipe
Não brande cada number na sua codebase. Comece com os parâmetros que causaram incidents reais.
- Identifique os últimos três bugs relacionados a units em produção. Procure por milliseconds vs seconds, currencies em diferentes denominations, latitude vs longitude, ou screen coordinates vs document coordinates.
- Brande aqueles types específicos. Adicione constructors e conversion functions.
- Atualize as functions onde esses valores são usados. Deixe o compiler te guiar.
- Adicione uma lint rule que proíba
numbercru para esses parâmetros.
Não brande loop counters, array indices ou percentages. Esses são dimensionless. Adicionar uma brand ali é cerimônia sem valor.
Se você está trabalhando em uma linguagem com nominal typing mais forte, você tem opções melhores. Usuários de Rust devem olhar para a crate uom. F# e OCaml têm units of measure embutidos no compiler. O structural type system do TypeScript faz disso um workaround, não uma feature de primeira classe. Mas o workaround é bom o suficiente para pegar bugs reais.
FAQ
Isso adiciona algum overhead em runtime?
Não. A brand é um construct apenas de tempo de compilação. Após a compilação, meters(100) é apenas o number 100. Não há wrapper object, nenhuma propriedade extra, nenhuma runtime check.
E quanto a multiplication e division?
Você precisa de explicit functions ou overloaded operators. TypeScript não suporta operator overloading, então distance / time deve passar por uma function per() ou speed(). Isso é verboso, mas também é o motivo pelo qual o bug é pego.
Posso usar isso com third-party libraries?
Somente se a library aceitar seu branded type. Se setTimeout espera number, você pode passar Milliseconds porque a brand é uma intersection com number. O inverso não é verdade. Se uma library retorna number, você deve brandeá-lo explicitamente antes de usá-lo como um unit type.
Como eu lido com fractional units como HalfSeconds?
Use a base unit e um constructor. halfSeconds(1) retorna Milliseconds(500). Não crie uma brand para cada subdivision. Mantenha a contagem de base units pequena.
Pare de escrever nomes de units em nomes de variáveis
Chamar uma variável de timeoutInMs é documentação. Documentação se desgasta. Chamá-la de timeout: Milliseconds é um type. Types são impostos.
Da próxima vez que você depurar um problema e perceber que o timeout estava errado por um fator de mil, pergunte a si mesmo se o nome da variável foi suficiente. Não foi. Codifique a unit no type, deixe o compiler fazer o trabalho e pare de confiar em si mesmo para lembrar se essa função particular quer seconds ou milliseconds.