No último trimestre, entregamos um bug que reembolsou o cliente errado. Uma função processRefund recebeu um user ID onde esperava um order ID. As strings pareciam idênticas, os testes passaram, e TypeScript não levantou nenhuma objeção.

Ambos os identifiers eram tipados como string. TypeScript não vê diferença entre eles. Se você já passou um userId em um parâmetro que quer um orderId e viu o compiler dar de ombros, você bateu no mesmo muro.

Type aliases não corrigem isso. Nomear suas variáveis com cuidado também não. O compiler está fazendo exatamente o que você mandou, e esse é o problema.

Por que type UserId = string é uma mentira

TypeScript usa structural typing. Dois tipos são compatíveis se suas estruturas combinarem. Quando você escreve type UserId = string, você não criou um novo tipo. Você criou um alias. Em tempo de compilação, UserId e string são idênticos. UserId e OrderId também são.

type UserId = string;
type OrderId = string;

function fetchOrder(id: OrderId) {
  // ...
}

const userId: UserId = "usr_0192";
fetchOrder(userId); // Compila. Oops.

O compiler remove type aliases durante o type checking. O nome UserId é para humanos. O type checker ignora ele. Isso geralmente é uma feature. Permite trocar implementações sem cerimônia. Mas para identifiers que nunca deveriam ser intercambiáveis, é uma footgun.

Branded types tornam estruturas idênticas incompatíveis

A solução é um branded type, às vezes chamado de opaque type ou newtype. Você intersecta o tipo subjacente com uma brand única que existe apenas no nível de tipo.

Em runtime, o valor ainda é apenas uma string. Em tempo de compilação, a brand o torna distinto de qualquer outra string.

Aqui está o padrão usando um unique symbol:

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };

O unique symbol garante que nenhum outro tipo possa acidentalmente compartilhar essa brand. A propriedade readonly significa que você não pode mutá-la. Em runtime, a propriedade symbol não existe na string real, então isso é puramente um construct de tempo de compilação.

Para criar um valor, você precisa de uma constructor function que faça o cast da string bruta:

function UserId(value: string): UserId {
  return value as UserId;
}

function OrderId(value: string): OrderId {
  return value as OrderId;
}

Agora o bug anterior é pego antes mesmo de você executar o código:

const uid = UserId("usr_0192");
fetchOrder(uid);
//     ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

A mensagem de erro é clara. Os tipos são estruturalmente diferentes porque suas brands diferem. O compiler não vai deixar você misturá-los.

O boilerplate é chato. Aqui está um helper.

Escrever uma brand e um constructor para cada tipo de ID envelhece rápido. Usamos uma pequena utility que gera ambos:

interface Brand<T> {
  readonly __brand: T;
}

type Branded<T, B> = T & Brand<B>;

function makeBrand<T, B>(
  _brand: B
): (value: T) => Branded<T, B> {
  return (value) => value as Branded<T, B>;
}

Uso:

type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");

type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");

Isso mantém a declaração em duas linhas por identifier. Você também pode usar a abordagem de unique symbol em um helper genérico se preferir a maior resistência a colisões. De qualquer forma, o objetivo é o mesmo: tornar o custo de adicionar um novo branded type baixo o suficiente para que você realmente o faça.

E objetos e números?

Branded types funcionam para qualquer primitive, não apenas strings. nodes brandamos numeric IDs da mesma forma:

type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");

Você também pode brandar objetos. Se você tem duas configurations que compartilham o mesmo shape mas nunca deveriam ser trocadas, brande-as:

type ApiConfig = Branded<{
  endpoint: string;
  timeout: number;
}, "ApiConfig">;

Mas tome cuidado com object branding. O objeto em runtime não terá a propriedade brand, então JSON.stringify e operações de spread comportam-se normalmente. Isso geralmente é o que você quer. Mas se você estiver fazendo deep equality checks ou passando valores para libraries que inspecionam tipos em runtime, a brand não estará lá para te ajudar. É estritamente uma guarda de tempo de compilação.

Os trade-offs são reais, mas pequenos

Branded types adicionam fricção. Cada ID agora precisa de uma constructor call. Você não pode inline uma string bruta em uma função que espera um branded type. Esse é o ponto, mas significa mais código.

Serialization é outro gotcha. Quando você faz JSON.stringify em uma branded string, você recebe a string bruta de volta. Quando a parseia depois, você perdeu a brand. Você precisa reaplicar o constructor nos boundaries do sistema. Geralmente fazemos isso em parsers de API response e database row mappers.

const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));

Branded types também não ajudam com runtime validation. Se uma string está malformada, a brand não vai pegar. Você ainda precisa de zod, valibot, ou validação manual nas edges do seu sistema. A brand garante type distinctness, não data correctness.

Quando isso vale a pena, e quando é noise

nodes brandamos identifiers que cruzam module boundaries: user IDs, organization IDs, trace IDs, span IDs. Esses são os valores que viajam pelo maior código e têm a maior chance de serem passados na ordem errada.

Não brandamos internal loop counters, local temp variables, ou qualquer coisa com escopo menor que uma função. O overhead não vale a pena para valores que nunca saem do lugar onde nasceram.

Se seu codebase tem uma function signature com três parâmetros string consecutivos, isso é um sinal forte. Branded types transformam aquele call site de um jogo de adivinhação em algo que o compiler verifica para você.

Um ponto de partida prático

Você não precisa brandar toda string no seu codebase amanhã. Escolha os identifiers que causaram bugs reais. Adicione brands para eles. Observe o compiler pegar uma mistura durante seu próximo refactor. Aquele único momento, em que um build error previne um bug de produção, é quando as constructor calls extras começam a parecer baratas.

Comece com um module boundary. Adicione uma brand UserId. Adicione uma brand OrderId. Atualize sua database layer para aplicar os constructors quando as rows chegarem. Veja como é. Se isso pegar um argumento errado em code review, já se pagou.