Type-State Pattern em Rust: Estados em Tempo de Compilação

Guia completo do Type-State Pattern em Rust: estados como parâmetros de tipo, PhantomData, transições verificadas pelo compilador e exemplos práticos de conexão HTTP e builder seguro.

O Type-State Pattern é um padrão idiomático do Rust que codifica estados como parâmetros de tipo genéricos, de modo que transições inválidas se tornam erros de compilação. Diferente do State Pattern clássico (que usa enums e valida transições em runtime), o Type-State move toda a lógica de validação de estado para o sistema de tipos.

A ideia central é simples e poderosa: se um objeto está no estado A, apenas métodos válidos para o estado A estão disponíveis. Tentar chamar um método do estado B resulta em erro de compilação, não em erro de runtime. O compilador se torna o guardião da máquina de estados.

Problema

Considere uma conexão HTTP que precisa passar por etapas: primeiro conectar, depois autenticar, só então enviar requisições. Em uma API convencional:

// PERIGOSO: nada impede o uso incorreto
struct ConexaoHttp {
    url: String,
    conectado: bool,
    autenticado: bool,
    token: Option<String>,
}

impl ConexaoHttp {
    fn enviar(&self, dados: &str) -> Result<String, String> {
        // Verificações em RUNTIME — descobertas só durante execução
        if !self.conectado {
            return Err("Não conectado!".to_string());
        }
        if !self.autenticado {
            return Err("Não autenticado!".to_string());
        }
        Ok(format!("Enviado: {}", dados))
    }
}

O usuário pode chamar enviar() sem conectar ou autenticar. O erro só aparece em runtime, possivelmente em produção.

Solução em Rust

Type-State com marcadores de tipo

use std::marker::PhantomData;

/// Marcadores de estado — tipos de tamanho zero (ZST)
/// Não ocupam memória, existem apenas no sistema de tipos
struct Desconectada;
struct Conectada;
struct Autenticada;

/// Conexão HTTP com estado codificado no tipo
struct ConexaoHttp<Estado> {
    url: String,
    headers: Vec<(String, String)>,
    _estado: PhantomData<Estado>,
}

/// Métodos disponíveis APENAS quando Desconectada
impl ConexaoHttp<Desconectada> {
    /// Cria uma nova conexão (ainda não conectada)
    fn nova(url: &str) -> Self {
        println!("[NOVA] Conexão criada para {}", url);
        ConexaoHttp {
            url: url.to_string(),
            headers: Vec::new(),
            _estado: PhantomData,
        }
    }

    /// Conecta ao servidor — transforma Desconectada em Conectada
    /// Note: consome self (ownership) e retorna novo tipo!
    fn conectar(self) -> Result<ConexaoHttp<Conectada>, String> {
        println!("[CONECTAR] Estabelecendo conexão com {}...", self.url);
        // Simulando conexão bem-sucedida
        Ok(ConexaoHttp {
            url: self.url,
            headers: self.headers,
            _estado: PhantomData,
        })
    }
}

/// Métodos disponíveis APENAS quando Conectada
impl ConexaoHttp<Conectada> {
    /// Autentica com credenciais — transforma Conectada em Autenticada
    fn autenticar(mut self, usuario: &str, senha: &str) -> Result<ConexaoHttp<Autenticada>, String> {
        println!("[AUTH] Autenticando como {}...", usuario);

        // Simulando autenticação
        if usuario.is_empty() || senha.is_empty() {
            return Err("Credenciais vazias".to_string());
        }

        self.headers.push((
            "Authorization".to_string(),
            format!("Bearer token_{}_{}", usuario, senha.len()),
        ));

        Ok(ConexaoHttp {
            url: self.url,
            headers: self.headers,
            _estado: PhantomData,
        })
    }

    /// Autenticação com token — transforma Conectada em Autenticada
    fn autenticar_com_token(mut self, token: &str) -> ConexaoHttp<Autenticada> {
        println!("[AUTH] Autenticando com token...");
        self.headers.push((
            "Authorization".to_string(),
            format!("Bearer {}", token),
        ));

        ConexaoHttp {
            url: self.url,
            headers: self.headers,
            _estado: PhantomData,
        }
    }

    /// Desconecta — volta ao estado Desconectada
    fn desconectar(self) -> ConexaoHttp<Desconectada> {
        println!("[DESCONECTAR] Conexão fechada");
        ConexaoHttp {
            url: self.url,
            headers: Vec::new(),
            _estado: PhantomData,
        }
    }
}

/// Métodos disponíveis APENAS quando Autenticada
impl ConexaoHttp<Autenticada> {
    /// Envia uma requisição GET — só funciona quando autenticado!
    fn get(&self, caminho: &str) -> Result<String, String> {
        println!("[GET] {} {}", self.url, caminho);
        Ok(format!("200 OK - {}{}", self.url, caminho))
    }

    /// Envia uma requisição POST
    fn post(&self, caminho: &str, corpo: &str) -> Result<String, String> {
        println!("[POST] {} {} - corpo: {}B", self.url, caminho, corpo.len());
        Ok(format!("201 Created - {}{}", self.url, caminho))
    }

    /// Fecha a conexão e volta ao estado Desconectada
    fn desconectar(self) -> ConexaoHttp<Desconectada> {
        println!("[DESCONECTAR] Sessão encerrada");
        ConexaoHttp {
            url: self.url,
            headers: Vec::new(),
            _estado: PhantomData,
        }
    }
}

/// Métodos disponíveis em QUALQUER estado
impl<Estado> ConexaoHttp<Estado> {
    /// Adiciona um header customizado
    fn com_header(mut self, chave: &str, valor: &str) -> Self {
        self.headers.push((chave.to_string(), valor.to_string()));
        self
    }

    /// Retorna a URL da conexão
    fn url(&self) -> &str {
        &self.url
    }
}

fn main() {
    // Fluxo correto: Nova -> Conectada -> Autenticada -> usar
    let resposta = ConexaoHttp::nova("https://api.exemplo.com")
        .com_header("Accept", "application/json")
        .conectar()
        .expect("Falha ao conectar")
        .autenticar("admin", "senha123")
        .expect("Falha ao autenticar")
        .get("/usuarios")
        .expect("Falha na requisição");

    println!("Resposta: {}", resposta);

    // ============================================================
    // Os exemplos abaixo NÃO COMPILAM — segurança em compilação!
    // ============================================================

    // ERRO: Desconectada não tem método get()
    // let conn = ConexaoHttp::nova("https://api.exemplo.com");
    // conn.get("/dados"); // ERRO: method not found in ConexaoHttp<Desconectada>

    // ERRO: Conectada não tem método post()
    // let conn = ConexaoHttp::nova("https://api.exemplo.com")
    //     .conectar().unwrap();
    // conn.post("/dados", "{}"); // ERRO: method not found in ConexaoHttp<Conectada>

    // ERRO: Autenticada não tem método conectar()
    // let conn = ConexaoHttp::nova("https://api.exemplo.com")
    //     .conectar().unwrap()
    //     .autenticar("admin", "123").unwrap();
    // conn.conectar(); // ERRO: method not found in ConexaoHttp<Autenticada>
}

Diagrama

    Type-State: Estados como Tipos no Sistema de Tipos do Rust

    ┌─────────────────────────┐
    │  ConexaoHttp<Desconectada> │
    │─────────────────────────│
    │ + nova()                │
    │ + conectar() ──────────────┐
    │ + com_header()          │  │ consome self
    │                         │  │ retorna novo tipo
    │ ✗ get()  — NÃO EXISTE  │  │
    │ ✗ post() — NÃO EXISTE  │  │
    └─────────────────────────┘  │
                                  ▼
    ┌─────────────────────────┐
    │  ConexaoHttp<Conectada>   │
    │─────────────────────────│
    │ + autenticar() ────────────┐
    │ + autenticar_com_token()│  │ consome self
    │ + desconectar()         │  │ retorna novo tipo
    │ + com_header()          │  │
    │                         │  │
    │ ✗ get()  — NÃO EXISTE  │  │
    │ ✗ post() — NÃO EXISTE  │  │
    └─────────────────────────┘  │
                                  ▼
    ┌─────────────────────────┐
    │ ConexaoHttp<Autenticada>  │
    │─────────────────────────│
    │ + get()                 │
    │ + post()                │
    │ + desconectar()         │
    │ + com_header()          │
    │                         │
    │ ✗ conectar()  — NÃO EXISTE │
    │ ✗ autenticar()— NÃO EXISTE │
    └─────────────────────────┘

    PhantomData — Marcador de tamanho zero:

    struct Desconectada;   // size = 0 bytes
    struct Conectada;      // size = 0 bytes
    struct Autenticada;    // size = 0 bytes

    PhantomData<Estado>    // size = 0 bytes

    ConexaoHttp<Desconectada> e ConexaoHttp<Autenticada>
    têm EXATAMENTE o mesmo tamanho em memória!

Exemplo do Mundo Real

Um builder de consulta SQL com type-state garantindo a ordem correta das clausulas:

use std::marker::PhantomData;

/// Estados da construção da query
struct SemTabela;
struct ComTabela;
struct ComFiltro;
struct Pronta;

/// Builder de consulta SQL com type-state
struct QueryBuilder<Estado> {
    tabela: String,
    colunas: Vec<String>,
    filtros: Vec<String>,
    ordenacao: Option<String>,
    limite: Option<usize>,
    _estado: PhantomData<Estado>,
}

/// Ponto de entrada: ainda sem tabela definida
impl QueryBuilder<SemTabela> {
    fn selecionar() -> Self {
        QueryBuilder {
            tabela: String::new(),
            colunas: Vec::new(),
            filtros: Vec::new(),
            ordenacao: None,
            limite: None,
            _estado: PhantomData,
        }
    }

    /// Define a tabela — obrigatório antes de qualquer outra coisa
    fn de(self, tabela: &str) -> QueryBuilder<ComTabela> {
        QueryBuilder {
            tabela: tabela.to_string(),
            colunas: self.colunas,
            filtros: self.filtros,
            ordenacao: self.ordenacao,
            limite: self.limite,
            _estado: PhantomData,
        }
    }
}

/// Métodos disponíveis após definir tabela
impl QueryBuilder<ComTabela> {
    /// Adiciona coluna à seleção
    fn coluna(mut self, col: &str) -> Self {
        self.colunas.push(col.to_string());
        self
    }

    /// Adiciona todas as colunas
    fn todas_colunas(mut self) -> Self {
        self.colunas = vec!["*".to_string()];
        self
    }

    /// Adiciona um filtro WHERE
    fn onde(self, condicao: &str) -> QueryBuilder<ComFiltro> {
        let mut filtros = self.filtros;
        filtros.push(condicao.to_string());
        QueryBuilder {
            tabela: self.tabela,
            colunas: self.colunas,
            filtros,
            ordenacao: self.ordenacao,
            limite: self.limite,
            _estado: PhantomData,
        }
    }

    /// Constrói a query sem filtros
    fn construir(self) -> String {
        let colunas = if self.colunas.is_empty() {
            "*".to_string()
        } else {
            self.colunas.join(", ")
        };
        let mut sql = format!("SELECT {} FROM {}", colunas, self.tabela);
        if let Some(ord) = &self.ordenacao {
            sql.push_str(&format!(" ORDER BY {}", ord));
        }
        if let Some(lim) = self.limite {
            sql.push_str(&format!(" LIMIT {}", lim));
        }
        sql
    }
}

/// Métodos disponíveis após adicionar filtro
impl QueryBuilder<ComFiltro> {
    /// Adiciona filtro AND
    fn e(mut self, condicao: &str) -> Self {
        self.filtros.push(condicao.to_string());
        self
    }

    /// Adiciona ordenação
    fn ordenar_por(mut self, coluna: &str) -> Self {
        self.ordenacao = Some(coluna.to_string());
        self
    }

    /// Define limite de resultados
    fn limitar(mut self, n: usize) -> Self {
        self.limite = Some(n);
        self
    }

    /// Constrói a query SQL final
    fn construir(self) -> String {
        let colunas = if self.colunas.is_empty() {
            "*".to_string()
        } else {
            self.colunas.join(", ")
        };
        let filtros = self.filtros.join(" AND ");
        let mut sql = format!(
            "SELECT {} FROM {} WHERE {}",
            colunas, self.tabela, filtros
        );
        if let Some(ord) = &self.ordenacao {
            sql.push_str(&format!(" ORDER BY {}", ord));
        }
        if let Some(lim) = self.limite {
            sql.push_str(&format!(" LIMIT {}", lim));
        }
        sql
    }
}

fn main() {
    // Builder com type-state garante uso correto
    let query = QueryBuilder::selecionar()
        .de("usuarios")
        .coluna("nome")
        .coluna("email")
        .onde("idade > 18")
        .e("ativo = true")
        .ordenar_por("nome ASC")
        .limitar(50)
        .construir();

    println!("SQL: {}", query);
    // SELECT nome, email FROM usuarios WHERE idade > 18 AND ativo = true ORDER BY nome ASC LIMIT 50

    // Query sem filtro
    let query2 = QueryBuilder::selecionar()
        .de("produtos")
        .todas_colunas()
        .construir();

    println!("SQL: {}", query2);
    // SELECT * FROM produtos

    // ERRO DE COMPILAÇÃO: não pode construir sem definir tabela
    // QueryBuilder::selecionar().construir();

    // ERRO DE COMPILAÇÃO: não pode adicionar coluna sem tabela
    // QueryBuilder::selecionar().coluna("nome");

    // ERRO DE COMPILAÇÃO: não pode ordenar sem filtro
    // QueryBuilder::selecionar().de("t").ordenar_por("nome");
}

Quando Usar

  • APIs com protocolo rígido: Quando a ordem de chamadas importa (conectar antes de enviar)
  • Builders seguros: Garantir que campos obrigatórios foram preenchidos antes de build()
  • Máquinas de estado críticas: Sistemas financeiros, protocolos de rede, processos legais
  • Prevenção de uso incorreto: Quando o custo de usar a API errado é alto
  • Documentação via tipos: Os tipos comunicam o protocolo de uso da API

Quando NÃO Usar

  • Muitos estados: Se a máquina de estados tem 10+ estados, a explosão de tipos torna o código difícil de manter
  • Transições dinâmicas: Se o próximo estado depende de dados em runtime, use enum-based state
  • Coleções heterogêneas: Não é possível ter Vec<ConexaoHttp<_>> com diferentes estados (cada tipo é diferente)
  • Simplicidade: Se a API é simples e o uso incorreto é improvável, o overhead cognitivo não compensa

Variações em Rust

Sealed traits para estados

/// Módulo selado — não pode ser implementado fora
mod estados {
    /// Trait selada — apenas estados conhecidos implementam
    pub trait EstadoConexao: private::Sealed {}

    mod private {
        pub trait Sealed {}
        impl Sealed for super::Aberta {}
        impl Sealed for super::Fechada {}
    }

    pub struct Aberta;
    pub struct Fechada;

    impl EstadoConexao for Aberta {}
    impl EstadoConexao for Fechada {}
}

/// Agora só estados válidos podem ser usados como parâmetro
struct Conexao<E: estados::EstadoConexao> {
    _estado: PhantomData<E>,
}

Type-State com múltiplos eixos

/// Combinando múltiplos eixos de estado
struct Nao;
struct Sim;

struct Formulario<TemNome, TemEmail> {
    nome: Option<String>,
    email: Option<String>,
    _nome: PhantomData<TemNome>,
    _email: PhantomData<TemEmail>,
}

impl Formulario<Nao, Nao> {
    fn novo() -> Self {
        Formulario {
            nome: None,
            email: None,
            _nome: PhantomData,
            _email: PhantomData,
        }
    }
}

impl<E> Formulario<Nao, E> {
    fn com_nome(self, nome: &str) -> Formulario<Sim, E> {
        Formulario {
            nome: Some(nome.to_string()),
            email: self.email,
            _nome: PhantomData,
            _email: PhantomData,
        }
    }
}

impl<N> Formulario<N, Nao> {
    fn com_email(self, email: &str) -> Formulario<N, Sim> {
        Formulario {
            nome: self.nome,
            email: Some(email.to_string()),
            _nome: PhantomData,
            _email: PhantomData,
        }
    }
}

/// enviar() SÓ existe quando AMBOS os campos estão preenchidos
impl Formulario<Sim, Sim> {
    fn enviar(&self) -> String {
        format!(
            "Enviado: {} <{}>",
            self.nome.as_ref().unwrap(),
            self.email.as_ref().unwrap(),
        )
    }
}

fn main() {
    // Ordem não importa — ambos os caminhos funcionam
    let resultado = Formulario::novo()
        .com_nome("Maria")
        .com_email("maria@exemplo.com")
        .enviar();
    println!("{}", resultado);

    let resultado2 = Formulario::novo()
        .com_email("joao@exemplo.com")
        .com_nome("João")
        .enviar();
    println!("{}", resultado2);

    // ERRO: não pode enviar sem nome
    // Formulario::novo().com_email("x@x.com").enviar();

    // ERRO: não pode enviar sem email
    // Formulario::novo().com_nome("Ana").enviar();
}

Padrões Relacionados

  • State: Versão em runtime do type-state; usa enums em vez de tipos genéricos
  • Builder: Builder com type-state garante que campos obrigatórios foram preenchidos
  • Newtype: Os marcadores de estado são essencialmente newtypes de tamanho zero
  • RAII: Type-state pode incluir Drop para cleanup automático em transições

Conclusão

O Type-State Pattern representa o auge da programação orientada a tipos em Rust. Ao codificar estados como parâmetros de tipo genéricos, movemos a validação de transições de estado do runtime para o compilador. O custo é zero em runtime (PhantomData não ocupa memória), e o benefício é imenso: estados inválidos são literalmente irrepresentáveis no código. Este padrão é particularmente valioso em APIs públicas de bibliotecas, onde o custo de uso incorreto pode ser alto. Quando bem aplicado, o Type-State transforma o compilador em um guardião incansável do protocolo da sua API.