wandres.dev
PROGRAMACIÓN A NIVEL DE TIPOS · el sistema de tipos como lenguaje

Sealed traits y los límites de la programación a nivel de tipos

El patrón sealed cierra un trait público con un supertrait privado, de modo que solo tú puedas implementarlo: así evolucionas la API sin romper a nadie y razonas sobre un conjunto cerrado de implementadores. Cierra el nivel un recorrido por la frontera —GATs, `impl Trait` en traits— y por los límites deliberados del sistema: sin tipos dependientes plenos, con coherencia, límite de recursión y sin tipos de orden superior.

⏱ 21 min

Un trait público es, por defecto, una invitación abierta: cualquiera, en cualquier crate, puede implementarlo para sus tipos. A veces eso es justo lo que quieres. Otras, no: quieres un trait que la gente pueda usar como cota —T: MiTrait— pero que solo puedas implementar. Cerrarlo así se llama sealing, y con una técnica de tres líneas te devuelve dos poderes: evolucionar el trait —añadirle métodos— sin romper el código ajeno, y razonar sobre sus implementadores como sobre un conjunto cerrado, casi un enum. Con el patrón sealed en la mano cerramos también el nivel, asomándonos a la frontera del sistema de tipos y a los muros que Rust ha decidido, a conciencia, no derribar.

🎯 Al terminar esta lección sabrás
  • Implementar el patrón sealed con un supertrait privado para cerrar un trait público.
  • Justificar el sellado: evolucionar la API y razonar sobre un conjunto cerrado de implementadores.
  • Situar herramientas de frontera —GATs, impl Trait en traits— como extensiones de la misma idea.
  • Reconocer los límites deliberados: sin tipos dependientes plenos, coherencia, límite de recursión, sin HKT.

Cerrar un trait: el patrón sealed

La técnica se apoya en una asimetría de visibilidad. Se declara un trait privado —el sello— en un módulo privado, y el trait público lo exige como supertrait. Quien esté fuera puede nombrar el trait público, pero no el sello, así que no puede satisfacer su requisito:

pub mod formato {
    mod sello {
        pub trait Sellado {}        // privado: nadie de fuera puede nombrarlo
    }

    pub trait Formato: sello::Sellado {   // solo quien implemente Sellado puede implementar Formato
        fn extension(&self) -> &str;
    }

    pub struct Json;
    pub struct Toml;

    impl sello::Sellado for Json {}       // el sello se cierra aqui dentro
    impl sello::Sellado for Toml {}

    impl Formato for Json { fn extension(&self) -> &str { "json" } }
    impl Formato for Toml { fn extension(&self) -> &str { "toml" } }
}

Desde otro crate, formato::Formato sirve como cota —fn guardar<T: formato::Formato>(x: T)— pero implementarlo es imposible:

// En otro crate:
struct MiFormato;
// impl formato::Formato for MiFormato { ... }
// ERROR: no puedes implementar Formato sin implementar sello::Sellado,
// que es privado y ni siquiera puedes nombrar

Tres líneas —un módulo privado, un trait vacío, un supertrait— y el trait queda cerrado: usable por todos, implementable solo por ti.

Por qué sellar

Cerrar un trait no es mezquindad, es diseño. Compra tres cosas concretas.

Evolución sin rupturas. Si nadie fuera de tu crate implementa Formato, puedes añadirle un método nuevo —con o sin cuerpo por defecto— sin romper a ningún usuario, porque conoces todas las implementaciones que existen: las tuyas. Un trait abierto no tiene ese lujo; añadirle un método sin defecto rompe todas las implementaciones externas.

Razonar sobre un conjunto cerrado. Un trait sellado es casi un enum de tipos: sabes que sus únicos implementadores son los que tú escribiste. Eso te deja razonar exhaustivamente sobre ellos, igual que un match razona sobre las variantes de un enum. Es el reverso de #[non_exhaustive]: aquel abre un enum para poder crecer; el sellado cierra un trait para poder crecer sin que nadie dependa de la totalidad.

Invariantes garantizadas. Si solo tú implementas un trait, controlas que toda implementación respete las invariantes que tu API asume. Nadie colará una implementación tramposa. Aquí conecta con el typestate del orden anterior: sellar el trait de los estados posibles de una conexión cierra el conjunto de estados legales, y ningún crate externo puede inventarse uno nuevo.

El reverso exacto de esta técnica es #[non_exhaustive], que se pone sobre un enum o struct público para lo contrario: dejarlo abierto a crecer.

#[non_exhaustive]
pub enum Evento {   // podra ganar variantes en el futuro
    Clic,
    Tecla(char),
}

Un enum marcado así obliga a los match de crates externos a incluir un brazo comodín, de modo que añadir una variante mañana no rompa su código. Sellado y #[non_exhaustive] son dos caras de una misma moneda de evolución: aquel garantiza creceré yo solo, nadie más implementa esto; este avisa podré crecer, no asumas que están todas las variantes. Ambos protegen tu libertad de añadir sin romper a nadie.

Al filo del sistema

Sellar un trait es un ejercicio de cerrar el sistema para razonar mejor. En el filo opuesto están las herramientas que lo abren hacia más expresividad, cada una una extensión de la misma idea de programar con tipos.

Los GATs —tipos asociados genéricos—, estables desde Rust 1.65, permiten que un tipo asociado tenga sus propios parámetros, típicamente un tiempo de vida:

trait Prestador {
    type Prestado<'a> where Self: 'a;   // tipo asociado con parametro (GAT)
    fn prestar<'a>(&'a self) -> Self::Prestado<'a>;
}

Con ellos se expresan los lending iterators —iteradores que prestan referencias a su propio interior—, imposibles de escribir antes. impl Trait en posición de retorno dentro de traits —estable desde 1.75— es, a su vez, lo que hizo por fin posibles las async fn en traits. Ambas amplían qué puedes decir con tipos sin salir del lenguaje.

Y luego están los muros, levantados a conciencia:

🚪

GATs

Tipos asociados con parámetros propios; habilitan iteradores que prestan referencias a su interior. Estables desde 1.65.

impl Trait en traits

Retorno con impl Trait en métodos de trait; hizo posibles las async fn en traits. Estable desde 1.75.

🧱

Coherencia

La orphan rule prohíbe implementar un trait ajeno para un tipo ajeno: una implementación única por par.

🛑

Límite de recursión

La resolución de traits es Turing-completa; recursion_limit corta los cómputos de tipos que no terminan.

Rust no tiene tipos dependientes plenos: un tipo no puede depender de un valor de ejecución, solo de constantes conocidas en compilación. No tiene tipos de orden superior —higher-kinded types—: no puedes abstraer sobre Vec o Option como constructores sin aplicar; los GATs cubren algunos de esos casos, pero no todos. La especialización de implementaciones sigue inestable, por problemas de solidez sin resolver. Son fronteras, no fallos pendientes.

flowchart TB
s[Trait publico Formato] --> sup[Supertrait privado Sellado]
sup --> solo[Solo tu crate puede implementarlo]
solo --> evo[Anades metodos sin romper a nadie]
solo --> raz[Razonas sobre un conjunto cerrado]
style sup fill:#cba6f7,color:#11111b
style solo fill:#89b4fa,color:#11111b
style evo fill:#a6e3a1,color:#11111b
style raz fill:#a6e3a1,color:#11111b
Los límites del sistema de tipos no son defectos: son el precio elegido de la previsibilidad

Merece la pena entender por qué Rust dibuja sus límites donde los dibuja, porque revela una filosofía coherente. Todo sistema de tipos vive en tensión entre dos virtudes que tiran en direcciones opuestas: el poder —cuántas invariantes puedes expresar y hacer verificar— y la previsibilidad —si la comprobación siempre termina, si los mensajes de error se entienden, si la inferencia deduce lo que esperas sin que anotes cada paso—. Los lenguajes con tipos dependientes plenos, como Agda o Lean, maximizan el poder: puedes demostrar que tu función de ordenación devuelve una permutación efectivamente ordenada, codificado todo en el tipo. Pero el precio es que escribir tipos se vuelve escribir demostraciones, la inferencia se rinde y el programador se transforma en matemático. Rust eligió, deliberadamente, un punto distinto de esa curva, y cada uno de sus muros compra algo valioso a cambio de una renuncia concreta. La coherencia y la orphan rule sacrifican flexibilidad —no puedes implementar un trait ajeno para un tipo ajeno— para garantizar que jamás existan dos implementaciones en conflicto para el mismo par tipo-trait, de modo que el significado de x.into() nunca dependa de qué crates estén enlazados. El límite de recursión sacrifica cómputos de tipos ilimitados —la resolución de traits es Turing-completa— para que el compilador siempre termine, aunque sea con un error de desbordamiento en vez de un cuelgue. La ausencia de aritmética de const generics en estable sacrifica elegancia para que la igualdad de tipos siga siendo tratable. Y la ausencia de tipos de orden superior te obliga a rodeos con GATs a cambio de mantener la inferencia manejable. Ninguna de estas fronteras espera un parche que la elimine; son decisiones de ingeniería que compran una cosa preciosa —que el compilador sea una herramienta fiable y no un asistente caprichoso— renunciando a la última onza de poder expresivo. Programar a nivel de tipos en Rust es, por eso, un arte de lo posible: no se trata de codificar toda invariante imaginable, sino de reconocer cuáles caben con naturalidad en el sistema —un estado, una unidad, una dimensión, una propiedad, un conjunto cerrado— y dejar que el compilador las custodie, sabiendo que al otro lado del muro hay más poder, sí, pero también más peso del que un lenguaje de sistemas quiere, o debe, cargar. Dominar el nivel no es empujar esos muros: es saber exactamente qué cabe dentro de ellos y aprovecharlo hasta el fondo.

📝
Lo esencial de sealed y los límites

El patrón sealed cierra un trait público exigiendo como supertrait un trait privado que solo tu crate puede implementar; así evolucionas la API sin romper a nadie y razonas sobre un conjunto cerrado de implementadores, el reverso de #[non_exhaustive]. En la frontera de la expresividad, los GATs —tipos asociados con parámetros— y el impl Trait en traits amplían qué dices con tipos. Y hay muros deliberados: sin tipos dependientes plenos, sin tipos de orden superior, con coherencia y orphan rule, con límite de recursión sobre una resolución de traits Turing-completa, y sin especialización estable. No son defectos, sino el precio elegido de un compilador previsible.

⚔️ Cierra el trait, reconoce el muro
  1. Implementa el patrón sealed: un módulo privado con un trait Sellado, un trait público Formato: Sellado y dos tipos que lo implementen; escribe la impl externa que no compila y explica por qué.
  2. Argumenta dos beneficios concretos del sellado y compáralo con #[non_exhaustive] sobre un enum.
  3. Relaciona el sellado con el typestate del orden 3: ¿cómo cerrarías el conjunto de estados posibles de una conexión?
  4. Escribe un trait con un GAT type Prestado<'a> y explica qué patrón —imposible sin GATs— habilita.
  5. Enumera tres límites deliberados del sistema de tipos de Rust y, para cada uno, di qué compra Rust a cambio de esa renuncia.