Saltar al contenido

Typestate: el estado de un objeto vive en su tipo

Codificar el estado de un recurso como parámetro de tipo —una conexión Abierta frente a una Cerrada— reparte los métodos en bloques impl por estado, de modo que llamar a `enviar` sobre una conexión cerrada no es un error de ejecución, sino de compilación. Las transiciones consumen `self`, el dato real viaja entre estados y el patrón culmina en un builder que exige sus campos en el propio tipo.

Muchos recursos obedecen un protocolo: abrir antes de leer, conectar antes de enviar, autenticar antes de operar. El error clásico —llamar a una operación en el estado equivocado— suele detectarse tarde, en ejecución, cuando un método devuelve un Err o, peor, entra en pánico. El typestate lo detecta antes de ejecutar nada: mete el estado del objeto dentro de su tipo. Una Conexion<Abierta> y una Conexion<Cerrada> no son el mismo tipo con un campo distinto; son tipos diferentes, con métodos diferentes. Y como enviar solo existe para la primera, enviar por una conexión cerrada deja de ser un caso que manejar para volverse una frase que el compilador no te deja escribir.

  • Codificar el estado de un recurso como parámetro de tipo y repartir los métodos en bloques impl por estado.
  • Hacer que una transición inválida no compile consumiendo self en cada paso.
  • Transportar datos de ejecución entre estados mientras solo cambia el marcador fantasma.
  • Aplicar el typestate al patrón builder para exigir campos en tiempo de compilación.

Una conexión que cambia de tipo

El estado se codifica como un parámetro de tipo, y los métodos se reparten en bloques impl según ese estado. Los estados en sí son marcadores de tamaño cero; el dato real —aquí, el host— persiste entre transiciones, mientras solo cambia el PhantomData:

Rust
use std::marker::PhantomData;pub struct Cerrada;   // marcadores de estado: tipos de tamano ceropub struct Abierta;pub struct Conexion<S> {    host: String,                 // el dato real viaja entre estados    _estado: PhantomData<S>,}impl Conexion<Cerrada> {    pub fn nueva(host: String) -> Conexion<Cerrada> {        Conexion { host, _estado: PhantomData }    }    pub fn abrir(self) -> Conexion<Abierta> {          // consume la cerrada        // aqui ocurriria el handshake real usando self.host        Conexion { host: self.host, _estado: PhantomData }    }}impl Conexion<Abierta> {    pub fn enviar(&self, _datos: &[u8]) { /* solo con la conexion abierta */ }    pub fn cerrar(self) -> Conexion<Cerrada> {         // consume la abierta        Conexion { host: self.host, _estado: PhantomData }    }}

enviar vive únicamente en impl Conexion<Abierta>; abrir, únicamente en impl Conexion<Cerrada>. El resultado es un protocolo verificado por el compilador:

Rust
let c = Conexion::nueva("ejemplo.com".into()); // Conexion<Cerrada>let c = c.abrir();                             // Conexion<Abierta>c.enviar(b"hola");                             // OKlet c = c.cerrar();                            // Conexion<Cerrada>// c.enviar(b"otra vez");   // ERROR: enviar no existe para Conexion<Cerrada>

Llamar a enviar con la conexión cerrada no es un error que atrapes con un Result: es un error de compilación, porque el método literalmente no existe para ese tipo.

Por qué consumir self

Cada transición toma self por valor y devuelve el nuevo tipo. Esto es el corazón del patrón, no un detalle.

Consumir el valor, no prestarlo

Al mover el valor viejo, el borrow checker impide que alguien conserve la Conexion<Abierta> después de cerrarla: ese binding quedó movido y volver a usarlo no compila. Si las transiciones tomaran &mut self y mutaran un campo estado, el estado sería dinámico otra vez y necesitarías comprobarlo en ejecución. Es la linealidad —usar el valor exactamente una vez por transición— la que traslada la verificación del protocolo al compilador. Consumir self convierte el objeto en algo parecido a un tipo lineal: un recurso que fluye por una cadena de transformaciones sin que quede atrás ninguna copia en el estado anterior.

Como Abierta y Cerrada son tipos de tamaño cero, todo este andamiaje se evapora en el binario: en tiempo de ejecución, Conexion<Abierta> y Conexion<Cerrada> son idénticas —una String— y las transiciones son movimientos que el optimizador elimina. Seguridad de protocolo a coste cero.

El builder que exige sus campos

El typestate brilla en el patrón builder. Un builder acumula campos y al final los ensambla; el fallo habitual es llamar a construir sin haber puesto un campo obligatorio y descubrirlo en ejecución. Con un marcador por campo —puesto o no puesto— hacemos que construir solo exista cuando todos los obligatorios están:

Rust
use std::marker::PhantomData;pub struct Si;pub struct No;pub struct Peticion<TieneUrl, TieneMetodo> {    url: Option<String>,    metodo: Option<String>,    _u: PhantomData<TieneUrl>,    _m: PhantomData<TieneMetodo>,}impl Peticion<No, No> {    pub fn nueva() -> Peticion<No, No> {        Peticion { url: None, metodo: None, _u: PhantomData, _m: PhantomData }    }}impl<M> Peticion<No, M> {   // url solo se ofrece si aun no esta puesta    pub fn url(self, u: &str) -> Peticion<Si, M> {        Peticion { url: Some(u.into()), metodo: self.metodo, _u: PhantomData, _m: PhantomData }    }}impl<U> Peticion<U, No> {   // metodo solo se ofrece si aun no esta puesto    pub fn metodo(self, m: &str) -> Peticion<U, Si> {        Peticion { url: self.url, metodo: Some(m.into()), _u: PhantomData, _m: PhantomData }    }}impl Peticion<Si, Si> {     // construir SOLO existe con ambos campos puestos    pub fn construir(self) -> String {        format!("{} {}", self.metodo.unwrap(), self.url.unwrap())    }}

El método construir vive en impl Peticion<Si, Si>: no existe hasta que url y metodo han cambiado sus marcadores de No a Si. Olvidar uno no da un panic ni un Err, sino un método que no está:

Rust
let p = Peticion::nueva().url("/a").metodo("GET").construir(); // OK -> "GET /a"// let q = Peticion::nueva().url("/a").construir();  // ERROR: construir no existe sin metodo

Y como url solo se ofrece sobre Peticion<No, _>, tampoco puedes ponerla dos veces. El compilador verifica la corrección del montaje sin una sola comprobación en ejecución.

El typestate convierte un protocolo en una gramática que el compilador impone

Lo que acaba de ocurrir con la conexión y el builder tiene un nombre en la teoría de la programación y una historia que merece conocerse. La idea del typestate nació a comienzos de los años ochenta, en lenguajes experimentales como NIL y Hermes, con una ambición precisa: capturar en el tipo no solo de qué clase es un valor, sino en qué estado se encuentra dentro de su ciclo de vida, de modo que las operaciones legales cambien con el estado. Un fichero abierto y uno cerrado son ambos ficheros, pero solo uno se puede leer; el typestate hace que el tipo lo sepa. Durante décadas fue un tema de investigación difícil de llevar a los lenguajes convencionales, porque requiere que el sistema de tipos siga la identidad de un valor a través de sus mutaciones. Rust lo consigue casi por accidente, como corolario de dos rasgos que tiene por otras razones: el movimiento, que le permite consumir el valor viejo en cada transición, y los genéricos monomorfizados, que le permiten codificar el estado como un parámetro de tipo sin coste. La consecuencia es que puedes escribir, en Rust estable y sin trucos, lo que en teoría de tipos se conoce como tipos de sesión: una descripción de un protocolo —el orden legal de las operaciones— codificada de tal manera que el compilador rechaza cualquier programa que lo viole. Piénsalo como definir una gramática: los estados son los símbolos, las transiciones las reglas de producción, y un programa que llama a los métodos en un orden ilegal es una frase agramatical que el compilador se niega a analizar. La potencia de esto es difícil de exagerar. Categorías enteras de bugs —usar tras cerrar, enviar sin conectar, construir sin inicializar— no se convierten en errores más fáciles de encontrar, sino en frases que no se pueden pronunciar. Y como el estado vive en tipos de tamaño cero, la garantía no cuesta nada: el protocolo se disuelve en el binario dejando solo el código que de verdad importa. Cuando diseñes una API que tenga un orden correcto de uso, pregúntate si ese orden puede vivir en los tipos. Si puede, habrás sustituido un manual —recuerda llamar a open antes de read— por una ley que el compilador aplica sin excepción.

Lo esencial del typestate

El typestate codifica el estado de un recurso como parámetro de tipo, con marcadores de tamaño cero y PhantomData, y reparte los métodos en bloques impl por estado: una operación ilegal en cierto estado no existe como método y no compila. Las transiciones consumen self y devuelven el nuevo tipo, de modo que la linealidad impide reutilizar el estado viejo; el dato real persiste mientras solo cambia el marcador. Aplicado al builder, hace que construir solo exista cuando todos los campos obligatorios están puestos. Todo a coste cero, porque los marcadores se evaporan en el binario.

Codifica el protocolo en el tipo

  1. Implementa Conexion<Cerrada> y Conexion<Abierta> con abrir, enviar y cerrar; transcribe el error al llamar enviar sobre una cerrada.
  2. Añade un tercer estado Autenticada entre abierta y lista para operar, con su transición, y describe el grafo de estados resultante.
  3. Explica por qué las transiciones toman self por valor y qué se rompería con &mut self.
  4. Construye el builder Peticion con marcadores Si y No; comprueba que construir no existe si falta un obligatorio y añade un campo opcional que no bloquee construir.
  5. Argumenta el coste en ejecución de todo el andamiaje —marcadores de tamaño cero y PhantomData— y conéctalo con hacer irrepresentable el estado ilegal sin pagar nada.