Pular para o conteúdo
TypeScript

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 bizarras

O 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 link

Outro 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