Pasa un u64 de user ID a una función que espera un order ID, y Rust no se quejará. Ambos son u64. El compiler ve tipos idénticos, así que no puede ayudarte. Te enteras en runtime, usualmente en producción, usualmente después de un refactor que creías seguro.

Esta es exactamente la clase de bug que los newtypes existen para eliminar.

Un newtype es un tuple struct de un solo campo que envuelve un tipo existente: struct UserId(u64);. En tiempo de compilación, UserId y OrderId son incompatibles. En runtime, ocupan exactamente los mismos bytes que un u64 pelado. Sin allocations extra, sin indirección, sin costo.

¿Qué problema resuelve esto en realidad?

Todo lenguaje con tipos estáticos tiene este problema. Tienes dos valores con la misma representación pero significados diferentes. Claves de base de datos, unidades físicas, montos de moneda, porcentajes versus conteos raw. En C usarías un typedef, en Go un alias de tipo, pero esos son solo nombres para el mismo tipo subyacente. El compiler todavía los trata como idénticos.

El patrón newtype de Rust es diferente. struct UserId(u64); crea un tipo distinto. No puedes pasar un UserId donde se espera un OrderId. No puedes sumar accidentalmente un porcentaje a un conteo raw. El error aparece en tiempo de compilación, no en un incident de producción.

Esto es lo que la gente quiere decir con “hacer que los estados ilegales sean irrepresentables”. No es teórico. Una vez vi un endpoint de API que aceptaba un account ID y un monto de transferencia, ambos u64. Un refactor los intercambió en un caller. Nada se rompió durante la compilación. El dinero se movió al lugar equivocado. Con newtypes, ese refactor habría fallado en compilar antes de que alguien lo desplegara.

Cómo funcionan los newtypes bajo el capó

Un newtype en Rust es solo un struct con un campo sin nombre:

struct UserId(u64);
struct OrderId(u64);

El compiler trata a UserId como un tipo completamente separado de OrderId y de u64. Lo construyes explícitamente: let id = UserId(42);. Accedes al valor interno con id.0.

Como el struct tiene un solo campo con un tamaño conocido, el compiler aplica una optimización que en realidad no es una optimización, es simplemente cómo funcionan los structs. El layout de memoria de UserId es idéntico al de u64. Mismo tamaño, misma alineación, mismo ABI.

Puedes verificarlo tú mismo:

use std::mem;

assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());

No hay vtable, no hay discriminante, no hay objeto wrapper. El código máquina generado para pasar un UserId a una función es idéntico al de pasar un u64. El sistema de tipos impone la distinción. El runtime la borra.

Un ejemplo concreto: confundir píxeles y puntos

Aquí hay un patrón que me mordió una vez. Una biblioteca de gráficos tenía distancias tanto en píxeles como en puntos independientes del dispositivo. Ambos eran f32. Pasé points donde se esperaban pixels, y mi UI se renderizó a la mitad de tamaño en pantallas high-DPI. El compiler se quedó callado porque ambos eran f32.

Con 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;

    // This compiles:
    draw_line(scale_to_pixels(width, dpi));

    // This does not:
    // draw_line(width);
    // error: expected `Pixels`, found `Points`
}

El compiler rechaza el error antes de que ejecutes el programa. Los valores Points y Pixels usan los mismos cuatro bytes que un f32. La seguridad no cuesta nada en runtime.

Las contrapartidas de las que nadie te habla

Los newtypes no son gratis de escribir. Los métodos del tipo envuelto no se propagan automáticamente. Un u64 tiene wrapping_add, leading_zeros, docenas de métodos. Un UserId pelado no tiene ninguno a menos que los implementes tú mismo.

Tienes tres opciones, y solo una es buena.

Opción 1: Implementar Deref. Esto te da todos los métodos del tipo interno a través de auto-deref:

use std::ops::Deref;

struct UserId(u64);

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 { &self.0 }
}

Esto funciona, pero anula el propósito. Deref permite la coerción implícita de UserId a u64, lo que significa que puedes pasar un UserId donde se espere un u64. Pierdes la type safety que acabas de comprar. No hagas esto con newtypes.

Opción 2: Implementar todo manualmente. Esto es tedioso, pero solo expones las operaciones que tienen sentido para tu dominio. Para un tipo ID, quizás solo necesites Display, Debug, PartialEq, Eq y 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 un tipo numérico con aritmética, implementas Add, Sub, y así sucesivamente. Esto es boilerplate. Los macros derive estándar y crates como derive_more ayudan, pero sigue siendo más código que un tipo raw.

Opción 3: Usa el valor interno directamente cuando lo necesites. Esta es mi preferencia. Mantén el newtype en los límites de la API, desenvuélvelo con .0 cuando necesites el valor raw, y evita Deref por completo. Es ligeramente más verboso, pero preserva la garantía de seguridad.

Los macros derive que hacen esto soportable

La biblioteca estándar de Rust te da #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] gratis. Para aritmética, recurres a std::ops o a una 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)
    }
}

El compiler todavía genera el mismo código máquina que la suma raw de f64. El trait Add también es una zero-cost abstraction. Monomorfiza para inlinear la operación.

Cuándo los newtypes son la herramienta equivocada

No toda primitiva necesita un newtype. Si una función recibe un timeout_ms: u64 y solo se usa localmente, envolverlo añade ruido sin atrapar bugs reales. Reserva los newtypes para valores que cruzan límites de API, persisten en bases de datos, o representan conceptos donde confundirlos tiene consecuencias reales.

La serialización también se pone rara. Si estás usando serde, un UserId(u64) se serializa como un map {"0": 42} por defecto, no como 42. Necesitas #[serde(transparent)] para arreglarlo:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

Esto es fácil de olvidar y molesto de debuggear cuando tu API JSON de repente espera objetos en lugar de números.

Empieza con tus APIs públicas

Encuentra cualquier función que reciba múltiples argumentos del mismo tipo primitivo pero con significados diferentes. Envuelve los que cruzan límites de module o servicio. No derives Deref. Usa #[serde(transparent)] si estás serializando.

Si tienes instalado cargo-expand, ejecuta cargo expand en un newtype y mira el código generado. Verás que el struct es solo el valor raw con un nombre de tipo diferente. La afirmación de zero-cost no es marketing. Es literalmente lo que el compiler emite.