O TypeScript compara tipos pelo formato, então dois aliases de string são intercambiáveis. Um branded type acrescenta uma etiqueta que existe só no sistema de tipos, string & { readonly __brand: "UserId" }, o que torna um UserId incompatível com uma string simples e com todas as outras brands:
Em tempo de execução, userId é só a string "u_42". A brand é um rótulo de tempo de compilação, e a única função dela é impedir que IDs, unidades e strings validadas sejam misturados.
O problema: aliases são só nomes
Um type alias não cria um tipo novo. Ele dá um segundo nome a um tipo que já existe, e o compilador trata os dois nomes como a mesma coisa:
Isso é tipagem estrutural: o TypeScript verifica se o formato encaixa, e string encaixa em string. Para objetos, os formatos costumam ser diferentes; para IDs, emails, moedas e unidades, nunca são. As brands resolvem esse caso específico.
Como a brand funciona
string & { readonly __brand: "UserId" } é uma interseção: o valor precisa ser uma string e também ter uma propriedade __brand do tipo "UserId". Nenhuma string real tem essa propriedade, então nenhuma string simples é atribuível a ele:
index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
A direção que importa continua funcionando: um UserId é uma string, então você pode passá-lo para qualquer coisa que receba uma string, chamar .startsWith() nele ou colocá-lo em um template. A brand só bloqueia a entrada.
Funções construtoras que validam
A assertion as UserId é a única porta de entrada, e uma assertion não verifica nada. Coloque-a em uma única função que valida a entrada, e aí todo valor com brand no programa terá passado por essa verificação:
sendWelcome nunca verifica a entrada de novo, porque o tipo do parâmetro diz que a verificação já aconteceu. Essa é a ideia de "parse, don't validate": verifique na fronteira e depois carregue a prova no tipo. Um type guard também serve de construtor quando você prefere um boolean a uma exceção: function isEmail(s: string): s is Email.
Um helper Brand genérico
Escrever a interseção à mão para cada tipo fica repetitivo. Um pequeno generic faz isso uma vez só:
Aplicar brand a um número funciona do mesmo jeito que a uma string. Repare que amount / 100 é um number simples: aritmética sobre um número com brand dá um resultado sem brand, como mostrado mais abaixo.
Brands com unique symbol
Um nome de propriedade string como __brand parece uma propriedade de verdade: userId.__brand passa na verificação de tipos como "UserId", mas é undefined em tempo de execução, e duas bibliotecas podem escolher o mesmo nome. Uma chave unique symbol evita os dois problemas:
declare const brand: unique symbol declara um symbol que existe só para o verificador de tipos; a palavra-chave declare faz com que nenhum JavaScript seja gerado para ele. Como o symbol não é exportado do módulo, código em outros arquivos nem consegue dar nome à propriedade da brand, então fora desse módulo os únicos jeitos de obter um Meters são as funções que você exporta ou uma assertion as Meters.
Brands não custam nada em tempo de execução
A saída compilada não tem nenhum vestígio da brand. Estas são as linhas geradas para o exemplo de Meters, abaixo do cabeçalho de módulo que o compilador adiciona: o declare, os dois type aliases e todos os as sumiram, e a chamada suprimida na última linha ainda é executada.
function toMeters(feet) {
return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);
Um valor com brand é o primitivo simples: typeof dá "string" ou "number", JSON.stringify o escreve como sempre e as comparações funcionam como antes. O outro lado é que nada é verificado em tempo de execução, a menos que a sua função construtora verifique. Dados lidos de JSON, de um banco de dados ou de uma URL chegam como string, e só viram um UserId quando você os passa por essa função.
Aritmética e métodos perdem a brand
Operações sobre um valor com brand retornam o tipo base, porque a brand não faz parte do que + ou .slice() produzem:
type Cents = number & { readonly __brand: "Cents" };
const a = 500 as Cents;
const b = 250 as Cents;
const sum = a + b; // number, not Cents
const total: Cents = a + b; // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid
Normalmente é isso que você quer: somar dois valores em centavos dá centavos, mas multiplicar centavos por centavos não dá, e só você sabe quais operações mantêm o significado. Escreva pequenos helpers como addCents(a: Cents, b: Cents): Cents para as operações de que o seu código precisa.
Quando usar branded types
Use brands quando confundir dois valores do mesmo tipo primitivo é um risco real e o compilador não tem outro jeito de ajudar:
| Situação | Exemplos de brands |
|---|---|
| IDs de tabelas diferentes | UserId, OrderId, ProductId |
| Strings validadas | Email, Url, NonEmptyString, Slug |
| Unidades e moedas | Meters, Feet, Cents, Usd, Eur |
| Texto sanitizado ou escapado | SafeHtml, SqlIdentifier |
| Números com um intervalo | Percentage, PositiveInt |
Dispense-as para valores que nunca são confundidos e para tipos de objeto que já têm formatos diferentes. Bibliotecas de validação podem produzir branded types a partir de um schema: no Zod, z.string().brand<"UserId">() dá um schema cujo parse retorna um UserId com brand, o que evita escrever as funções construtoras à mão.
Perguntas frequentes
O que são branded types no TypeScript?
Um padrão que torna incompatíveis dois tipos com a mesma representação em tempo de execução. Você faz a interseção do tipo base com uma etiqueta que nenhum valor comum tem: type UserId = string & { readonly __brand: "UserId" }. Uma string simples, ou um OrderId com outra etiqueta, é então rejeitada onde se espera um UserId.
O TypeScript tem tipos nominais?
Não. O sistema de tipos do TypeScript é estrutural: dois tipos com o mesmo formato são intercambiáveis, sejam quais forem os nomes. Declarações de classe com membros private ou #private se comportam de forma nominal, e branded types são o jeito comum de obter o mesmo efeito para primitivos como strings e números.
Branded types têm custo em tempo de execução?
Não. A brand existe só no tipo. Em tempo de execução o valor continua sendo uma string ou um número simples, sem propriedade extra, e o JavaScript compilado é o mesmo que sem a brand. O único código em tempo de execução é a validação que você decidir colocar na função que cria os valores com brand.
Como criar um valor de um branded type?
Com uma type assertion, de preferência dentro de uma função pequena que verifica a entrada antes: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Manter o as nesse único lugar garante que todo Email do programa passou pela verificação.
Qual a diferença entre um type alias e um branded type?
type UserId = string é só um nome novo: qualquer string é aceita onde se espera um UserId. type UserId = string & { readonly __brand: "UserId" } é um tipo novo e incompatível: uma string simples precisa passar antes por uma função construtora ou por uma assertion.