Discriminated Unions em TypeScript
Baixar PDFO padrão mais poderoso de TypeScript para modelar estados, variantes e resultados — exemplos full-stack com React, NestJS e Express.
Discriminated Unions
Discriminated unions são o padrão mais importante de TypeScript para modelar dados que podem ter formas diferentes. São tão fundamentais que, uma vez dominados, você vai usá-los em literalmente todo projeto.
O conceito
Uma discriminated union é um tipo que pode ser uma de várias variantes, onde cada variante tem uma propriedade discriminante (comum a todas) que permite ao TypeScript saber em qual variante estamos.
// A propriedade "kind" é o discriminante
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };Quando você verifica shape.kind === 'circle', o TypeScript estreita o tipo automaticamente — e só permite acessar radius, não width nem height.
Analogia do dia a dia
Pense em um formulário de entrega:
- Se o tipo de entrega é "correios", precisa de CEP e código de rastreio
- Se é "retirada", precisa de data e horário
- Se é "motoboy", precisa de endereço e referência
Cada tipo de entrega tem campos diferentes. Mas todos são "entrega". A discriminated union garante que, quando você sabe que é "correios", o TypeScript sabe que cep existe.
Exemplo prático — Frontend (React)
Estado de requisição API
// Definição dos estados possíveis
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; statusCode?: number };
// Componente que renderiza baseado no estado
function ProductList() {
const [state, setState] = useState<ApiState<Product[]>>({ status: 'idle' });
const loadProducts = async () => {
setState({ status: 'loading' });
try {
const res = await fetch('/api/products');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setState({ status: 'success', data });
} catch (err) {
setState({
status: 'error',
error: err instanceof Error ? err.message : 'Erro desconhecido',
});
}
};
// ✅ O TS sabe exatamente quais propriedades existem em cada caso
switch (state.status) {
case 'idle':
return <button onClick={loadProducts}>Carregar produtos</button>;
case 'loading':
return <div className="spinner">Carregando...</div>;
case 'success':
// ✅ TS sabe que state.data existe e é Product[]
return (
<ul>
{state.data.map(p => <li key={p.id}>{p.name} — R$ {p.price}</li>)}
</ul>
);
case 'error':
// ✅ TS sabe que state.error existe e é string
return (
<div className="error">
<p>Erro: {state.error}</p>
<button onClick={loadProducts}>Tentar novamente</button>
</div>
);
}
}Formulário com múltiplos métodos de pagamento
// Cada método de pagamento tem campos diferentes
type PaymentMethod =
| { type: 'credit_card'; cardNumber: string; expiry: string; cvv: string }
| { type: 'pix'; cpf: string }
| { type: 'boleto'; cpf: string; fullName: string }
| { type: 'crypto'; walletAddress: string; network: 'ethereum' | 'bitcoin' };
function PaymentForm() {
const [method, setMethod] = useState<PaymentMethod>({ type: 'credit_card', cardNumber: '', expiry: '', cvv: '' });
const handleSubmit = () => {
// ✅ TS garante que só acessa campos que existem para cada tipo
switch (method.type) {
case 'credit_card':
processCreditCard(method.cardNumber, method.expiry, method.cvv);
break;
case 'pix':
generatePixQR(method.cpf);
break;
case 'boleto':
generateBoleto(method.cpf, method.fullName);
break;
case 'crypto':
sendCrypto(method.walletAddress, method.network);
break;
}
};
// ✅ TS sabe que os campos mudam conforme o type
return (
<form onSubmit={handleSubmit}>
{method.type === 'credit_card' && (
<>
<input value={method.cardNumber} onChange={e => setMethod({ ...method, cardNumber: e.target.value })} />
{/* TS só aceita setMethod com cardNumber, expiry, cvv — não cpf ou walletAddress */}
</>
)}
{method.type === 'pix' && (
<input value={method.cpf} onChange={e => setMethod({ ...method, cpf: e.target.value })} />
)}
</form>
);
}Exemplo prático — Backend (NestJS)
Result type para operações de negócio
type OrderResult =
| { ok: true; order: Order }
| { ok: false; error: 'INSUFFICIENT_STOCK'; available: number }
| { ok: false; error: 'PAYMENT_FAILED'; reason: string }
| { ok: false; error: 'USER_BLOCKED'; blockedUntil: Date };
class OrderService {
async createOrder(dto: CreateOrderDto): Promise<OrderResult> {
const product = await this.productRepo.findById(dto.productId);
if (product.stock < dto.quantity) {
return { ok: false, error: 'INSUFFICIENT_STOCK', available: product.stock };
}
const user = await this.userRepo.findById(dto.userId);
if (user.isBlocked) {
return { ok: false, error: 'USER_BLOCKED', blockedUntil: user.blockedUntil };
}
const payment = await this.paymentService.charge(dto.paymentMethod, dto.total);
if (!payment.success) {
return { ok: false, error: 'PAYMENT_FAILED', reason: payment.failureReason };
}
const order = await this.orderRepo.create(dto);
return { ok: true, order };
}
}
// Controller — trata cada caso com mensagem específica
class OrderController {
async create(req: Request, res: Response) {
const result = await this.orderService.createOrder(req.body);
if (result.ok) {
return res.status(201).json(result.order);
}
// ✅ TS sabe quais propriedades existem em cada erro
switch (result.error) {
case 'INSUFFICIENT_STOCK':
return res.status(409).json({
error: 'Estoque insuficiente',
available: result.available, // ✅ TS sabe que existe
});
case 'PAYMENT_FAILED':
return res.status(402).json({
error: 'Pagamento recusado',
reason: result.reason, // ✅ TS sabe que existe
});
case 'USER_BLOCKED':
return res.status(403).json({
error: 'Usuário bloqueado',
blockedUntil: result.blockedUntil, // ✅ TS sabe que existe
});
}
}
}Exemplo prático — Backend (Express)
Event system com discriminated unions
type AppEvent =
| { type: 'USER_REGISTERED'; userId: string; email: string; timestamp: Date }
| { type: 'ORDER_PLACED'; orderId: string; userId: string; total: number; timestamp: Date }
| { type: 'PAYMENT_RECEIVED'; paymentId: string; orderId: string; amount: number; timestamp: Date }
| { type: 'ORDER_SHIPPED'; orderId: string; trackingCode: string; timestamp: Date }
| { type: 'SUBSCRIPTION_CANCELLED'; subscriptionId: string; reason: string; timestamp: Date };
// Handler — cada evento é tratado com campos específicos
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'USER_REGISTERED':
emailService.sendWelcome(event.email); // ✅ event.email existe
analytics.track('signup', { userId: event.userId });
break;
case 'ORDER_PLACED':
inventoryService.reserve(event.orderId); // ✅ event.orderId existe
analytics.track('purchase', { total: event.total });
break;
case 'PAYMENT_RECEIVED':
orderService.markAsPaid(event.orderId); // ✅ event.orderId existe
receiptService.generate(event.paymentId);
break;
case 'ORDER_SHIPPED':
notificationService.sendTracking(event.orderId, event.trackingCode); // ✅ event.trackingCode existe
break;
case 'SUBSCRIPTION_CANCELLED':
churnAnalysis.record(event.reason); // ✅ event.reason existe
winBackCampaign.schedule(event.subscriptionId);
break;
}
}O poder do never — garantir exhaustiveness
TypeScript pode verificar se você tratou todos os casos de uma union. Basta usar never:
function assertNever(value: never): never {
throw new Error(`Valor inesperado: ${JSON.stringify(value)}`);
}
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'USER_REGISTERED': /* ... */ break;
case 'ORDER_PLACED': /* ... */ break;
// ❌ Se eu esquecer de tratar 'PAYMENT_RECEIVED', o TS dá erro:
// Argument of type '{ type: "PAYMENT_RECEIVED"; ... }' is not assignable to parameter of type 'never'
default:
assertNever(event); // ✅ TS garante que todos os casos foram tratados
}
}Resultado: Se alguém adicionar um novo tipo de evento à union, o compilador vai apontar todos os lugares que precisam ser atualizados.
Discriminated Union vs Herança
| Herança | Discriminated Union | |
|---|---|---|
| Adicionar variante | Criar subclasse | Adicionar ` |
| Verificar qual variante | instanceof (acoplamento) | switch no discriminante |
| Exhaustiveness check | ❌ Não | ✅ Sim (com never) |
| Serialização para JSON | ❌ Perde classe | ✅ Objeto puro |
| Padrão | Open for extension | Closed + exhaustive |
Em TypeScript, discriminated unions são quase sempre preferíveis a herança para modelar dados.
Referências
D — Dependency Inversion Principle
Dependa de abstrações, não de implementações concretas. Injeção de dependência em TypeScript full-stack — NestJS, Express e React.
Error Handling em TypeScript
Tratar erros como cidadãos de primeira classe — Result types, custom errors, narrowing de exceções em frontend e backend.