Todo cliente de API tem uma state machine escondida dentro dele. Handshake primeiro. Autenticação segundo. Envio de dados terceiro. Fechamento por último. Quebrar essa ordem e você obtém erros de runtime, servidores confusos, ou pior, corrupção silenciosa de dados.
A maioria das equipes codifica essas regras com verificações de runtime. if (!connected) throw new Error(...). Isso funciona até alguém esquecer a verificação, ou um refactor introduzir um novo caminho de código que a ignore. Quando você perceber, o bug já está em produção.
O sistema de tipos do TypeScript pode eliminar uma classe inteira desses bugs. Não com regras de lint inteligentes, mas tornando estados ilegais literalmente irrepresentáveis.
Seu cliente de API tem uma state machine escondida
Session types são uma técnica de sistema de tipos para codificar a sequência válida de operações em um protocol. Em vez de rastrear estado com um campo de string em runtime, você o rastreia no parâmetro de tipo. Um Channel<'idle'> só pode chamar connect(). Um Channel<'connected'> só pode chamar send() e close(). O compiler rejeita todo o resto.
Isso não é acadêmico. Se você já usou uma transaction de banco de dados, uma conexão WebSocket ou um fluxo OAuth, você já aplicou manualmente um protocol de session. Os session types apenas movem essa aplicação de runtime para tempo de compilação.
Phantom types tornam transições ilegais irrepresentáveis
Aqui está uma state machine típica em TypeScript:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
Isso está bem até não estar mais. O método send() lança em runtime se você o chamar na hora errada. Os testes podem capturar. Podem não capturar. Um refactor pode introduzir uma nova chamada send() depois de close() que ninguém nota. O compiler não tem opinião.
O TypeScript usa structural typing. Duas classes com a mesma forma são intercambiáveis, mesmo se seus parâmetros de tipo diferirem. Para tornar Channel<'idle'> e Channel<'connected'> incompatíveis, o parâmetro de tipo deve aparecer na própria estrutura.
O padrão comum é um campo phantom privado:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
O campo __state nunca recebe um valor. Ele existe apenas para vincular o parâmetro de tipo à estrutura da classe. Por ser privado, o código externo não pode fabricar um Channel<'connected'>. Por ter tipo State, Channel<'idle'> e Channel<'connected'> são tipos estruturalmente diferentes.
O parâmetro this em cada método restringe quais estados podem chamá-lo. O TypeScript verifica a compatibilidade de this no local da chamada, não apenas dentro do corpo do método.
Agora as chamadas ilegais são erros de tempo de compilação:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
Essa é a parte que confunde as pessoas. O parâmetro this parece uma anotação de runtime. Não é. O TypeScript o usa puramente para verificação de tipo do receptor. Se o tipo do receptor não corresponder, o compiler rejeita a chamada inteiramente.
Construindo um protocol de upload de arquivos type-safe
O Channel de brinquedo mostra o padrão. Aqui está algo mais próximo de um protocol real: um cliente de upload de arquivos com autenticação e verificação de checksum.
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
Observe que uploadChunk aceita Uploader<'ready' | 'uploading'> como seu tipo this. Union types permitem que você expresse métodos que são válidos a partir de múltiplos estados. O método retorna Uploader<'uploading'>, então após a primeira chamada você está bloqueado no estado de upload até finalizar.
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
Tente chamar authenticate() depois de uploadChunk(), ou uploadChunk() depois de verify(), e o TypeScript acusa erro imediatamente.
Onde o padrão fica estranho
O padrão não é de graça. O maior ponto de dor é que toda transição de estado constrói um novo objeto. Nos nossos exemplos criamos um novo Uploader mesmo quando o estado subjacente não mudou, como uploadChunk retornando Uploader<'uploading'> a partir de Uploader<'uploading'>. Para objetos complexos com muito estado, isso é desperdiçador.
Você pode otimizar passando o estado adiante em vez de reconstruir, mas os tipos ficam mais barulhentos. Um segundo problema: as mensagens de erro do TypeScript para incompatibilidades de parâmetro this são crípticas. “The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’” é precisa, mas não é imediatamente óbvia para alguém lendo o código pela primeira vez. Bons nomes e comentários ajudam, mas a experiência do desenvolvedor não é perfeita.
Terceiro, esse padrão não se compõe bem com código async. Um await no meio de uma cadeia quebra a interface fluente, e você precisa armazenar o resultado tipado intermediário em uma variável. Isso não é um impeditivo, mas significa que o padrão funciona melhor para protocols síncronos ou async cuidadosamente estruturados.
Tagged unions são o meio-termo pragmático
Se o padrão completo de phantom types parecer pesado, tagged unions com instruções switch exaustivas são uma alternativa pragmática. Você ainda obtém segurança em tempo de compilação, mas no nível de valor em vez do nível de tipo:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
Isso é mais idiomático do TypeScript e mais fácil para a maioria das equipes manter. A compensação é que você não pode impedir alguém de passar a variante de estado errada para uma função no nível de tipo sem os mesmos truques de parâmetro this. O compiler captura dentro da função, mas o local da chamada não é restrito.
Use phantom types quando violações causam dano real
Use phantom types quando o protocol for complexo o suficiente para que violá-lo cause dano real, e quando a superfície da API for pequena o suficiente para que você controle cada transição. Drivers de banco de dados, implementações de protocols de rede e SDKs stateful são bons candidatos.
Não os use para APIs CRUD simples ou para qualquer coisa onde a complexidade de tipo extra custe mais do que a verificação de runtime ocasional.
Da próxima vez que você estiver escrevendo if (state !== 'connected') throw new Error(...), pergunte se essa verificação pertence ao seu código ou aos seus tipos. Os parâmetros this e os campos phantom do TypeScript lhe dão uma forma de empurrar a aplicação do protocol para cima. Os bugs que você capturar não serão os dramáticos. Eles serão os erros silenciosos que escapam pela revisão de código e só aparecem quando um cliente encontra um edge case que você não testou.
Esses são exatamente os bugs que valem a pena eliminar.