Quando você modela entidades com TypeScript, é muito comum obter uma interface como esta:
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
O Problema
Os tipos das propriedades não têm significado semântico. Em termos de tipos, User.id
, Order.id
, Order.year
, etc. são iguais: um número, e como número eles são intercambiáveis, mas semanticamente, não são.
Seguindo o exemplo anterior, podemos ter um conjunto de funções que executam ações sobre as entidades. Por exemplo:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Essas funções aceitarão qualquer número em qualquer argumento, independentemente do significado semântico do número. Por exemplo:
const id = getUserId()
deleteOrder(id)
Obviamente, isso é um grande erro, e poderia parecer fácil evitar lendo o código, mas o código nem sempre é tão simples quanto o exemplo.
O mesmo acontece com getOrdersFiltered
: podemos trocar os valores de dia e mês, e não receberemos nenhum aviso ou erro. Os erros acontecerão se o dia for maior que 12, mas é óbvio que o resultado não será o esperado.
A Solução
As regras de calistenia de objetos fornecem uma solução para isso: encapsular todos os tipos primitivos e Strings (Relacionado com o anti-padrão de obsessão primitiva). A regra é envolver os primitivos em um objeto que represente um significado semântico (DDD descreve isso como ValueObjects
).
Mas com TypeScript, não precisamos usar classes ou objetos para isso: podemos usar o sistema de tipos para garantir que um número que represente algo diferente de um ano não possa ser usado no lugar de um ano.
Tipos Marcados
Este padrão utiliza a extensibilidade de tipos para adicionar uma propriedade que garante o significado semântico:
type Year = number & { __brand: 'year' }
Esta simples linha cria um novo tipo que pode funcionar como um número — mas não é um número, é um ano.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
Generalizando a Solução
Para evitar escrever um tipo por tipo marcado, podemos criar um tipo utilitário como:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Que utiliza um símbolo único como nome da propriedade de marca para evitar conflitos com suas propriedades e obtém o tipo original e a marca como parâmetros genéricos.
Com isso, podemos refatorar nossos modelos e funções da seguinte forma:
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
Agora, neste exemplo, a IDE mostrará um erro, pois id
é um UserId
e deleteOrder
espera um OrderId
.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Compromissos
Como um pequeno compromisso, você precisará usar X
como Brand
. Por exemplo, const year = 2012 as Year
ao criar um novo valor a partir de um primitivo, mas isso é equivalente a um new Year(2012)
se você usar objetos de valor. Você pode fornecer uma função que funcione como uma espécie de “construtor”:
function year(year: number): Year {
return year as Year
}
Validação com Tipos Marcados
Tipos marcados também são úteis para garantir que os dados sejam válidos, pois você pode ter tipos específicos para dados validados, e pode confiar que o usuário foi validado apenas usando tipos:
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
não é obrigatório, mas para garantir que seu código não altere os dados após validá-los, é altamente recomendado.
Resumo
Tipos marcados são uma solução simples que inclui o seguinte:
- Melhora a legibilidade do código: Torna mais claro qual valor deve ser usado em cada argumento
- Confiabilidade: Ajuda a evitar erros no código que podem ser difíceis de detectar; agora o IDE (e a verificação de tipos) nos ajuda a detectar se o valor está no lugar correto
- Validação de dados: Você pode usar tipos nomeados para garantir que os dados sejam válidos.
Você pode pensar em tipos nomeados como uma espécie de versão de ValueObjects
, mas sem usar classes — apenas tipos e funções.
Desfrute do poder dos tipos!
Source:
https://dzone.com/articles/branded-types-in-typescript