Passe um user ID u64 para uma função que espera um order ID, e Rust não vai reclamar. Ambos são u64. O compiler vê tipos idênticos, então não pode te ajudar. Você descobre em runtime, geralmente em produção, geralmente depois de um refactor que você achou que era seguro.
Essa é exatamente a classe de bug que newtypes existem para eliminar.
Um newtype é uma tuple struct de campo único que envolve um tipo existente: struct UserId(u64);. Em tempo de compilação, UserId e OrderId são incompatíveis. Em runtime, eles ocupam exatamente os mesmos bytes de um u64 cru. Sem alocações extras, sem indireção, sem custo.
Que problema isso realmente resolve?
Toda linguagem com tipos estáticos tem esse problema. Você tem dois valores com a mesma representação, mas significados diferentes. Chaves de banco de dados, unidades físicas, valores de moeda, porcentagens versus contagens cruas. Em C você usaria um typedef, em Go um type alias, mas esses são apenas nomes para o mesmo tipo subjacente. O compiler ainda os trata como idênticos.
O padrão newtype de Rust é diferente. struct UserId(u64); cria um tipo distinto. Você não pode passar um UserId onde um OrderId é esperado. Você não pode acidentalmente adicionar uma porcentagem a uma contagem crua. O erro aparece em tempo de compilação, não em um incident de produção.
É disso que as pessoas falam quando dizem “making illegal states unrepresentable”. Não é teórico. Uma vez vi um endpoint de API que aceitava um account ID e um transfer amount, ambos u64. Um refactor os trocou em um caller. Nada quebrou durante a compilação. Dinheiro foi para o lugar errado. Com newtypes, aquele refactor teria falhado na compilação antes que alguém o publicasse.
Como newtypes funcionam por baixo do capô
Um newtype em Rust é apenas uma struct com um campo sem nome:
struct UserId(u64);
struct OrderId(u64);
O compiler trata UserId como um tipo completamente separado de OrderId e de u64. Você o constrói explicitamente: let id = UserId(42);. Você acessa o valor interno com id.0.
Como a struct tem um campo único com tamanho conhecido, o compiler aplica uma otimização que na verdade não é uma otimização — é apenas como structs funcionam. O layout de memória de UserId é idêntico ao de u64. Mesmo tamanho, mesmo alignment, mesma ABI.
Você pode verificar isso você mesmo:
use std::mem;
assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());
Não há vtable, não há discriminant, não há wrapper object. O código de máquina gerado para passar um UserId para uma função é idêntico ao de passar um u64. O type system impõe a distinção. O runtime a apaga.
Um exemplo concreto: confundir pixels e points
Aqui está um padrão que me mordeu uma vez. Uma biblioteca gráfica tinha distâncias tanto em pixels quanto em device-independent points. Ambos eram f32. Eu passei points onde pixels eram esperados, e minha UI renderizou em metade do tamanho em telas high-DPI. O compiler ficou em silêncio porque ambos eram f32.
Com newtypes:
struct Pixels(f32);
struct Points(f32);
fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
Pixels(points.0 * dpi / 96.0)
}
fn draw_line(length: Pixels) {
// render at this pixel length
}
fn main() {
let width = Points(150.0);
let dpi = 192.0;
// Isso compila:
draw_line(scale_to_pixels(width, dpi));
// Isso não:
// draw_line(width);
// error: expected `Pixels`, found `Points`
}
O compiler rejeita o erro antes mesmo de você executar o programa. Os valores Points e Pixels usam os mesmos quatro bytes de um f32. A segurança não custa nada em runtime.
Os trade-offs que ninguém te conta
Newtypes não são de graça para escrever. Os métodos do tipo envolvido não se propagam automaticamente. Um u64 tem wrapping_add, leading_zeros, dezenas de métodos. Um UserId cru não tem nenhum deles a menos que você os implemente você mesmo.
Você tem três opções, e apenas uma delas é boa.
Opção 1: Implementar Deref. Isso te dá todos os métodos do tipo interno através de auto-deref:
use std::ops::Deref;
struct UserId(u64);
impl Deref for UserId {
type Target = u64;
fn deref(&self) -> &u64 { &self.0 }
}
Isso funciona, mas derrota o propósito. Deref permite coerção implícita de UserId para u64, o que significa que você pode passar um UserId em qualquer lugar onde um u64 é esperado. Você perde a type safety que acabou de comprar. Não faça isso para newtypes.
Opção 2: Implementar tudo manualmente. Isso é tedioso, mas você só expõe as operações que fazem sentido para seu domínio. Para um tipo de ID, talvez você só precise de Display, Debug, PartialEq, Eq e Hash:
use std::fmt;
#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
Para um tipo numérico com aritmética, você implementa Add, Sub, e assim por diante. Isso é boilerplate. As macros derive padrão e crates como derive_more ajudam, mas ainda é mais código do que um tipo cru.
Opção 3: Use o valor interno diretamente quando precisar. Essa é minha preferência. Mantenha o newtype nos limites da API, unwrap com .0 quando precisar do valor cru, e evite Deref completamente. É ligeiramente mais verboso, mas preserva a safety guarantee.
As macros derive que tornam isso suportável
A biblioteca padrão de Rust te dá #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] de graça. Para aritmética, você recorre a std::ops ou uma crate como derive_more:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);
impl std::ops::Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
O compiler ainda gera o mesmo código de máquina que a adição crua de f64. A trait Add também é uma zero-cost abstraction. Ela monomorphizes para inline na operação.
Quando newtypes são a ferramenta errada
Nem toda primitiva precisa de um newtype. Se uma função recebe um timeout_ms: u64 e é usada apenas localmente, envolvê-lo adiciona ruído sem capturar bugs reais. Reserve newtypes para valores que cruzam limites de API, persistem em bancos de dados, ou representam conceitos onde misturá-los tem consequências reais.
Serialization também fica estranha. Se você está usando serde, um UserId(u64) serializa como um mapa {"0": 42} por padrão, não como 42. Você precisa de #[serde(transparent)] para consertar isso:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
Isso é fácil de esquecer e irritante de debugar quando sua API JSON de repente espera objetos em vez de números.
Comece com suas APIs públicas
Encontre qualquer função que receba múltiplos argumentos do mesmo tipo primitivo, mas com significados diferentes. Envolver os que cruzam limites de module ou serviço. Não derive Deref. Use #[serde(transparent)] se estiver serializando.
Se você tem cargo-expand instalado, execute cargo expand em um newtype e olhe o código gerado. Você verá que a struct é apenas o valor cru com um nome de tipo diferente. A afirmação de zero custo não é marketing. É literalmente o que o compiler emite.