Newtype Pattern en Rust: type safety con wrappers de coste cero

Imagina que tienes una función crear_pedido(cliente_id: u64, producto_id: u64, cantidad: u32). Los parámetros son tipos primitivos. Si en una llamada confundes el orden del cliente_id y el producto_id, el compilador no dice nada: ambos son u64. El error solo aparecerá en tiempo de ejecución.

El newtype pattern resuelve esto creando un tipo distinto por cada concepto, aunque por dentro contengan el mismo tipo primitivo:

struct ClienteId(u64);
struct ProductoId(u64);

fn crear_pedido(cliente_id: ClienteId, producto_id: ProductoId, cantidad: u32) {
    // ...
}

// Ahora esto no compila: los tipos son distintos
// crear_pedido(producto_id, cliente_id, 5);  // Error en compilación

La estructura básica del newtype

Un newtype es una struct con un solo campo, normalmente llamado 0 (campo de tupla) o con un nombre explícito:

// Estilo tupla (más común)
struct UsuarioId(u64);
struct Email(String);
struct Precio(f64);

// Estilo con nombre (más legible en some contexts)
struct UsuarioId {
    valor: u64,
}
struct Email {
    direccion: String,
}

El estilo de tupla es más compacto y es el que se usa habitualmente. El acceso al valor interior es con .0:

let id = UsuarioId(42);
println!("ID: {}", id.0);

Implementar From e Into

Para facilitar la creación y conversión, implementa los traits From e Into:

struct UsuarioId(u64);

impl From<u64> for UsuarioId {
    fn from(valor: u64) -> Self {
        UsuarioId(valor)
    }
}

impl From<UsuarioId> for u64 {
    fn from(id: UsuarioId) -> Self {
        id.0
    }
}

// Uso
let id = UsuarioId::from(42);
let id: UsuarioId = 42.into();
let valor: u64 = id.into();
let valor = u64::from(UsuarioId(42));

Implementar From<T> for MiTipo da automáticamente la implementación de Into<MiTipo> for T, así que solo necesitas implementar uno de los dos en cada dirección.

Display y Debug

Los tipos wrapper deberían mostrar su valor de forma útil:

use std::fmt;

struct Email(String);

impl fmt::Display for Email {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl fmt::Debug for Email {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Email({})", self.0)
    }
}

// O más fácil, derivar ambos:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Email(String);

Validación en el constructor

El newtype es el lugar natural para la lógica de validación. Si el tipo solo se puede crear con un constructor que valida, garantizas que cualquier valor del tipo es válido:

#[derive(Debug, Clone)]
struct Email(String);

impl Email {
    pub fn new(direccion: impl Into<String>) -> Result<Self, String> {
        let direccion = direccion.into();
        if !direccion.contains('@') {
            return Err(format!("Email inválido: {}", direccion));
        }
        if direccion.len() > 254 {
            return Err("Email demasiado largo".to_string());
        }
        Ok(Email(direccion))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

// Solo se puede crear a través del constructor
let email = Email::new("[email protected]")?;
// Email("invalido")  // Esto no compila si el campo es privado

Con el campo privado (sin pub), nadie puede crear un Email que no pase la validación. Cualquier función que reciba un Email sabe que es válido.

AsRef y Deref para transparencia

Cuando quieres que el newtype se comporte como su tipo interior en ciertos contextos, implementa AsRef o Deref:

use std::ops::Deref;

struct Nombre(String);

impl AsRef<str> for Nombre {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl Deref for Nombre {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

// Con AsRef, funciona donde se espera &str:
fn saludar(nombre: &str) { println!("Hola, {}", nombre); }
let nombre = Nombre("Carlos".to_string());
saludar(nombre.as_ref());

// Con Deref, funciona directamente:
saludar(&nombre);  // Coerce automático
println!("{}", nombre.to_uppercase());  // Métodos de str disponibles

Usa Deref con cuidado: puede hacer que el tipo newtype se comporte de forma inesperada y perder parte de la protección de tipos. Prefiere AsRef cuando solo necesitas conversión explícita.

Coste cero en tiempo de ejecución

El compilador de Rust garantiza que los newtypes tienen el mismo layout de memoria que su tipo interior. No hay indirección, no hay heap allocation adicional, no hay coste en runtime:

use std::mem::size_of;

assert_eq!(size_of::<u64>(), size_of::<UsuarioId>());
assert_eq!(size_of::<String>(), size_of::<Email>());
assert_eq!(size_of::<f64>(), size_of::<Precio>());

Esto es lo que hace que el patrón sea práctico en Rust: obtienes seguridad de tipos sin pagar ningún coste en tiempo de ejecución.

Newtypes para implementar traits externos

Las reglas de orphan (huérfano) de Rust prohíben implementar un trait externo para un tipo externo. Los newtypes son la forma estándar de saltarse esta restricción:

use std::fmt;

// No puedes: impl fmt::Display for Vec<String> {}
// Sí puedes con un newtype:
struct ListaTextos(Vec<String>);

impl fmt::Display for ListaTextos {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

let lista = ListaTextos(vec!["uno".into(), "dos".into(), "tres".into()]);
println!("{}", lista);  // [uno, dos, tres]

Ejemplo completo: un sistema de IDs tipados

use std::marker::PhantomData;

// Un ID genérico parametrizado por el tipo de entidad
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Id<T>(u64, PhantomData<T>);

impl<T> Id<T> {
    pub fn new(valor: u64) -> Self {
        Id(valor, PhantomData)
    }

    pub fn valor(&self) -> u64 {
        self.0
    }
}

// Tipos de entidad (sin datos, solo como marcadores)
struct Usuario;
struct Producto;
struct Pedido;

// Alias de tipo para legibilidad
type UsuarioId = Id<Usuario>;
type ProductoId = Id<Producto>;
type PedidoId = Id<Pedido>;

fn crear_pedido(
    cliente: UsuarioId,
    producto: ProductoId,
    cantidad: u32,
) -> PedidoId {
    println!(
        "Pedido: usuario={}, producto={}, cantidad={}",
        cliente.valor(), producto.valor(), cantidad
    );
    PedidoId::new(1)
}

let usuario_id = UsuarioId::new(42);
let producto_id = ProductoId::new(99);

// OK
crear_pedido(usuario_id, producto_id, 3);

// ERROR en compilación: tipos distintos
// crear_pedido(producto_id, usuario_id, 3);

Con PhantomData<T>, el tipo Id<Usuario> y el tipo Id<Producto> son incompatibles para el compilador aunque internamente contengan el mismo u64. Y la diferencia en el binario compilado es cero bytes.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP