State Machines com Discriminated Unions
State machines tipadas eliminam estados impossíveis do seu código. O compilador garante que toda transição é válida e todo estado é tratado.
Por que isso é importante
State Machines com Discriminated Unions. State machines tipadas eliminam estados impossíveis do seu código. O compilador garante que toda transição é válida e todo estado é tratado.
O Problema dos Estados Booleanos
Todo dev já escreveu código assim. Você começa com um loading simples:
interface UserState {
isLoading: boolean;
user: User | null;
error: Error | null;
}
// Parece OK... até você precisar de mais estados
const state: UserState = {
isLoading: true,
user: null,
error: null
};Aí o código cresce e você adiciona mais flags. Agora você tem 3 booleanos: isLoading, isError, hasData. Isso cria 8 combinações possíveis (2³). Dá pra ter isLoading=true, hasData=true, isError=true tudo ao mesmo tempo.
Dessas 8 combinações, só 3 ou 4 fazem sentido. Mas nada impede você de criar estados impossíveis. E quando isso acontece, o bug é doloroso de rastrear.
// Estados válidos:
// 1. isLoading=true, user=null, error=null (carregando)
// 2. isLoading=false, user=User, error=null (sucesso)
// 3. isLoading=false, user=null, error=Error (erro)
// Estados impossíveis mas permitidos:
// 4. isLoading=true, user=User, error=Error (??? o quê?)
// 5. isLoading=false, user=User, error=Error (sucesso E erro?)
// 6. isLoading=true, user=null, error=Error (carregando com erro?)
// ... e mais 2 combinações bizarrasO problema não é só teórico. Eu já debuguei bugs onde o estado tinha isLoading=false, user=undefined, error=null. Nenhum dos três estados possíveis. O código não sabia o que renderizar.
Discriminated unions resolvem isso na raiz. Ao invés de 8 combinações, você define exatamente os estados válidos. O TypeScript garante que estados impossíveis não compilam.
Discriminated Unions: A Solução
A sacada é usar um campo discriminante. Um campo comum em todas as variantes da union que identifica qual variante você tá usando.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Só 4 estados possíveis. Não dá pra ter success com error.
// Não dá pra ter loading com data.
type UserState = AsyncState<User>;
const loading: UserState = { status: 'loading' };
const success: UserState = { status: 'success', data: { id: 1, name: 'João' } };
const error: UserState = { status: 'error', error: new Error('Not found') };O campo status é o discriminante. Ele identifica qual variante da union você tem. E cada variante tem seus próprios campos específicos.
Se o status é 'success', você tem data. Se é 'error', você tem error. Se é 'loading', você não tem nada além do status. Estados impossíveis simplesmente não existem no sistema de tipos.
// Isso não compila:
const invalid: UserState = {
status: 'success',
// Faltou data - TypeScript não deixa
};
// Isso também não:
const alsoBad: UserState = {
status: 'loading',
data: user // loading não tem data
};Esse pattern funciona porque o TypeScript faz type narrowing baseado no discriminante. Quando você checa o status, o compilador sabe exatamente quais campos estão disponíveis.
Dá pra usar qualquer nome de campo como discriminante, mas 'type', 'kind' e 'status' são os mais comuns. O importante é que seja uma string literal, não uma string genérica.
Type Narrowing Automático
A mágica acontece quando você usa switch ou if pra checar o discriminante. O TypeScript automaticamente sabe quais campos existem dentro de cada bloco.
function renderUser(state: UserState) {
switch (state.status) {
case 'idle':
return 'Aguardando...';
case 'loading':
return 'Carregando...';
case 'success':
// Aqui dentro, TypeScript sabe que state.data existe
return `Olá, ${state.data.name}`;
case 'error':
// Aqui dentro, TypeScript sabe que state.error existe
return `Erro: ${state.error.message}`;
}
}Dentro do case 'success', você pode acessar state.data sem TypeScript reclamar. Dentro do case 'error', você tem state.error. O compilador fez narrowing automático baseado no discriminante.
Se você tentar acessar state.data fora do case 'success', TypeScript bloqueia. Porque nos outros casos, data não existe.
function broken(state: UserState) {
// Erro! data pode não existir
console.log(state.data); // Property 'data' does not exist
if (state.status === 'success') {
// OK! Aqui o TypeScript sabe que data existe
console.log(state.data.name);
}
}Você também pode usar if/else ao invés de switch. O narrowing funciona igual:
function renderAlternative(state: UserState) {
if (state.status === 'loading') {
return 'Carregando...';
}
if (state.status === 'error') {
return `Erro: ${state.error.message}`;
}
if (state.status === 'success') {
return `Olá, ${state.data.name}`;
}
// idle
return 'Aguardando...';
}O padrão com switch é mais comum porque fica visualmente claro que você tá lidando com estados mutuamente exclusivos. Mas ambos funcionam perfeitamente.
Exhaustive Check Pattern
Aqui vem a parte mais poderosa: garantir que você tratou todos os casos. Se amanhã você adicionar um novo estado, o TypeScript quebra até você atualizar todos os switches.
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function renderWithExhaustiveCheck(state: UserState) {
switch (state.status) {
case 'idle':
return 'Aguardando...';
case 'loading':
return 'Carregando...';
case 'success':
return `Olá, ${state.data.name}`;
case 'error':
return `Erro: ${state.error.message}`;
default:
// Se chegou aqui, você esqueceu um case
return assertNever(state);
}
}A função assertNever aceita só tipo never. Se todos os cases foram tratados, o default nunca é alcançado e state é tipo never. Se você esquecer um case, state não é never e o TypeScript reclama.
Vamos adicionar um novo estado pra ver isso funcionando:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
| { status: 'refreshing'; data: T }; // Novo estado!
// Agora renderWithExhaustiveCheck quebra:
// Argument of type '{ status: "refreshing"; data: User; }'
// is not assignable to parameter of type 'never'O compilador te força a adicionar o case 'refreshing'. Você não consegue commitar código incompleto. É impossível esquecer de atualizar algum lugar.
Isso é especialmente poderoso em grandes codebases. Você pode ter 30 funções que fazem switch no estado. Quando você adiciona um novo estado, todas as 30 quebram até você atualizar.
// Pattern alternativo sem assertNever:
function render(state: UserState): string {
switch (state.status) {
case 'idle': return 'Aguardando...';
case 'loading': return 'Carregando...';
case 'success': return `Olá, ${state.data.name}`;
case 'error': return `Erro: ${state.error.message}`;
}
// Se você esquecer um case, TypeScript reclama aqui:
// Function lacks ending return statement
}Sem o default com assertNever, o TypeScript reclama que a função pode não retornar nada. Ambos os patterns funcionam, mas assertNever dá mensagens de erro mais claras.
State Machine Tipada
State machines são discriminated unions no esteróide. Você não só define os estados possíveis, mas também quais transições são válidas entre eles.
Vamos construir uma máquina de estados simples pra um player de música:
type PlayerState =
| { type: 'stopped' }
| { type: 'playing'; track: Track; position: number }
| { type: 'paused'; track: Track; position: number };
type PlayerEvent =
| { type: 'PLAY'; track: Track }
| { type: 'PAUSE' }
| { type: 'RESUME' }
| { type: 'STOP' };Agora você define um reducer que só aceita transições válidas:
function playerReducer(
state: PlayerState,
event: PlayerEvent
): PlayerState {
switch (state.type) {
case 'stopped':
switch (event.type) {
case 'PLAY':
return { type: 'playing', track: event.track, position: 0 };
default:
// PAUSE, RESUME, STOP são inválidos quando stopped
return state;
}
case 'playing':
switch (event.type) {
case 'PAUSE':
return { type: 'paused', track: state.track, position: state.position };
case 'STOP':
return { type: 'stopped' };
case 'PLAY':
// Trocar de música
return { type: 'playing', track: event.track, position: 0 };
default:
return state;
}
case 'paused':
switch (event.type) {
case 'RESUME':
return { type: 'playing', track: state.track, position: state.position };
case 'STOP':
return { type: 'stopped' };
case 'PLAY':
// Trocar de música
return { type: 'playing', track: event.track, position: 0 };
default:
return state;
}
}
}O reducer define exatamente quais eventos são válidos em cada estado. Você não consegue pausar quando já tá stopped. Você não consegue resumir quando tá playing.
E o TypeScript garante que você não esqueceu nenhum estado ou evento. Se você adicionar um novo estado 'buffering', o código quebra até você definir como ele reage a cada evento.
// Uso:
let state: PlayerState = { type: 'stopped' };
state = playerReducer(state, { type: 'PLAY', track: myTrack });
// state = { type: 'playing', track: myTrack, position: 0 }
state = playerReducer(state, { type: 'PAUSE' });
// state = { type: 'paused', track: myTrack, position: 0 }
state = playerReducer(state, { type: 'RESUME' });
// state = { type: 'playing', track: myTrack, position: 0 }
state = playerReducer(state, { type: 'STOP' });
// state = { type: 'stopped' }Você pode integrar isso com React usando useReducer. O estado do componente sempre reflete um estado válido da máquina.
Caso Real: Checkout Flow
Vamos modelar um fluxo de checkout completo. Isso mostra como state machines escalam pra cenários complexos.
type CheckoutState =
| { type: 'idle' }
| { type: 'selecting'; items: CartItem[] }
| { type: 'reviewing'; items: CartItem[]; address: Address }
| { type: 'paying'; items: CartItem[]; address: Address; paymentMethod: PaymentMethod }
| { type: 'processing'; orderId: string }
| { type: 'completed'; orderId: string; receipt: Receipt }
| { type: 'failed'; error: Error; canRetry: boolean };Cada estado tem exatamente os dados que fazem sentido naquele momento. Quando você tá selecting, você não tem address ainda. Quando você tá reviewing, você tem items e address mas não tem paymentMethod.
type CheckoutEvent =
| { type: 'ADD_ITEM'; item: CartItem }
| { type: 'REMOVE_ITEM'; itemId: string }
| { type: 'PROCEED_TO_REVIEW' }
| { type: 'SET_ADDRESS'; address: Address }
| { type: 'SET_PAYMENT'; method: PaymentMethod }
| { type: 'SUBMIT_ORDER' }
| { type: 'PAYMENT_SUCCESS'; orderId: string; receipt: Receipt }
| { type: 'PAYMENT_FAILED'; error: Error; canRetry: boolean }
| { type: 'RETRY_PAYMENT' }
| { type: 'CANCEL' };Agora o reducer com transições válidas:
function checkoutReducer(
state: CheckoutState,
event: CheckoutEvent
): CheckoutState {
switch (state.type) {
case 'idle':
if (event.type === 'ADD_ITEM') {
return { type: 'selecting', items: [event.item] };
}
return state;
case 'selecting':
switch (event.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, event.item] };
case 'REMOVE_ITEM':
const items = state.items.filter(i => i.id !== event.itemId);
return items.length > 0
? { ...state, items }
: { type: 'idle' };
case 'PROCEED_TO_REVIEW':
// Precisa de address pra ir pra reviewing
return state;
default:
return state;
}
case 'reviewing':
switch (event.type) {
case 'SET_PAYMENT':
return {
type: 'paying',
items: state.items,
address: state.address,
paymentMethod: event.method
};
case 'CANCEL':
return { type: 'selecting', items: state.items };
default:
return state;
}
case 'paying':
if (event.type === 'SUBMIT_ORDER') {
// Inicia processamento
return { type: 'processing', orderId: generateOrderId() };
}
if (event.type === 'CANCEL') {
return { type: 'reviewing', items: state.items, address: state.address };
}
return state;
case 'processing':
if (event.type === 'PAYMENT_SUCCESS') {
return {
type: 'completed',
orderId: event.orderId,
receipt: event.receipt
};
}
if (event.type === 'PAYMENT_FAILED') {
return {
type: 'failed',
error: event.error,
canRetry: event.canRetry
};
}
return state;
case 'failed':
if (event.type === 'RETRY_PAYMENT' && state.canRetry) {
// Volta pra processing
return { type: 'processing', orderId: generateOrderId() };
}
if (event.type === 'CANCEL') {
return { type: 'idle' };
}
return state;
case 'completed':
// Estado final, não aceita eventos
return state;
}
}Esse reducer garante que você nunca vai processar pagamento sem items, address e paymentMethod. Você não consegue retryar um pagamento que não falhou. Você não consegue completar sem passar por processing.
E o melhor: quando você renderiza a UI, você sabe exatamente quais dados existem:
function CheckoutUI({ state }: { state: CheckoutState }) {
switch (state.type) {
case 'idle':
return <EmptyCart />;
case 'selecting':
return <CartItems items={state.items} />;
case 'reviewing':
return (
<ReviewOrder
items={state.items}
address={state.address}
/>
);
case 'paying':
return (
<PaymentForm
items={state.items}
address={state.address}
method={state.paymentMethod}
/>
);
case 'processing':
return <ProcessingSpinner orderId={state.orderId} />;
case 'completed':
return <OrderConfirmation receipt={state.receipt} />;
case 'failed':
return (
<PaymentError
error={state.error}
canRetry={state.canRetry}
/>
);
}
}Você nunca vai tentar renderizar address quando ele não existe. Você nunca vai mostrar botão de retry quando canRetry é false. O TypeScript te protege de tudo isso.
Discriminated Unions em React
Dá pra usar discriminated unions direto nas props dos componentes. Isso garante que props dependentes sempre vêm juntas.
type ButtonProps =
| { variant: 'primary'; onClick: () => void }
| { variant: 'secondary'; onClick: () => void }
| { variant: 'link'; href: string };
function Button(props: ButtonProps) {
if (props.variant === 'link') {
// TypeScript sabe que props.href existe aqui
return <a href={props.href}>Link</a>;
}
// Aqui TypeScript sabe que props.onClick existe
return (
<button
className={props.variant}
>
Button
</button>
);
}Se variant é 'link', você tem href. Se é 'primary' ou 'secondary', você tem onClick. Você não consegue passar onClick pra um link ou href pra um button.
// OK:
<Button variant="primary" => console.log('click')} />
<Button variant="link" href="/about/" />
// Erro de compilação:
<Button variant="primary" href="/about/" /> // href não existe em primary
<Button variant="link" => {}} /> // onClick não existe em linkOutro exemplo comum é formulários com diferentes modos:
type FormProps =
| { mode: 'create'; onSubmit: (data: FormData) => void }
| { mode: 'edit'; initialData: FormData; onSubmit: (data: FormData) => void }
| { mode: 'view'; data: FormData };
function UserForm(props: FormProps) {
switch (props.mode) {
case 'create':
return <CreateForm />;
case 'edit':
return (
<EditForm
initialData={props.initialData}
/>
);
case 'view':
return <ViewData data={props.data} />;
}
}No modo 'create', você não tem initialData. No modo 'view', você não tem onSubmit. Cada modo tem exatamente as props que fazem sentido.
Isso evita props opcionais confusas tipo initialData?: FormData onde você não sabe se ela vai existir ou não. Com discriminated unions, fica explícito.
XState + TypeScript
Se você precisa de state machines complexas, XState é a lib padrão. E ela tem suporte completo a TypeScript.
import { createMachine } from 'xstate';
const playerMachine = createMachine({
id: 'player',
initial: 'stopped',
states: {
stopped: {
on: { PLAY: 'playing' }
},
playing: {
on: {
PAUSE: 'paused',
STOP: 'stopped'
}
},
paused: {
on: {
RESUME: 'playing',
STOP: 'stopped'
}
}
}
});XState gera tipos automaticamente baseado na configuração. Você tem autocomplete pra todos os estados e eventos.
import { useMachine } from '@xstate/react';
function Player() {
const [state, send] = useMachine(playerMachine);
return (
<div>
<p>Estado: {state.value}</p>
{state.matches('stopped') && (
<button => send('PLAY')}>Play</button>
)}
{state.matches('playing') && (
<>
<button => send('PAUSE')}>Pause</button>
<button => send('STOP')}>Stop</button>
</>
)}
{state.matches('paused') && (
<>
<button => send('RESUME')}>Resume</button>
<button => send('STOP')}>Stop</button>
</>
)}
</div>
);
}XState também tem visualizador gráfico. Você literalmente vê o diagrama da state machine e pode testar transições visualmente.
Pra casos simples, você não precisa de XState. Um reducer com discriminated unions resolve bem. Mas quando você tem máquinas com hierarquia (nested states), parallel states, guards complexos, XState vale muito a pena.
// XState com context tipado:
interface PlayerContext {
track: Track | null;
position: number;
volume: number;
}
type PlayerEvent =
| { type: 'PLAY'; track: Track }
| { type: 'PAUSE' }
| { type: 'SET_VOLUME'; volume: number };
const machine = createMachine<PlayerContext, PlayerEvent>({
context: { track: null, position: 0, volume: 50 },
// ... states
});O TypeScript infere todos os tipos e te protege de erros. Se você tentar enviar um evento que não existe, erro de compilação. Se você tentar acessar um campo do context que não existe, erro.
Checklist de State Machines Type-Safe
- Use discriminated unions com campo discriminante único (status, type, kind)
- Cada variante tem apenas os campos que fazem sentido naquele estado
- Implemente exhaustive checks com assertNever ou análise de fluxo
- Defina tipos separados pra State e Event
- Use reducer pattern pra controlar transições válidas
- Aproveite type narrowing em switch/if pra acessar campos específicos
- Evite estados booleanos múltiplos que criam combinações impossíveis
- Documente transições válidas em comentários ou diagramas
- Use const assertions em eventos: as const
- Considere XState pra máquinas complexas com hierarquia
- Teste que todos os estados podem ser alcançados
- Garanta que não existem estados deadlock (sem transições de saída)
- Use discriminated unions em props React pra dependências obrigatórias
- Mantenha estados finais explícitos (completed, cancelled, etc.)
- Aproveite type inference do TypeScript ao máximo