Pré-requisitos: Os exemplos de código requerem TypeScript 4.0 ou posterior e um tsconfig.json com "target": "ES2016" ou superior e "strict": true. O FormState o alias de tipo recursivo requer TypeScript 3.7+.
Sinalizadores booleanos emparelhados com campos opcionais são uma fonte comum de bugs de estado impossível em bases de código TypeScript. As uniões discriminadas eliminam esses estados inválidos em tempo de compilação, de modo que o compilador rejeita objetos que combinam campos ilegalmente. Este artigo traça o padrão desde sua forma mais simples, passando pelas armadilhas comuns da construção de membros a partir de booleanos, até uma máquina de estado de formulário de várias etapas totalmente digitada com um redutor exaustivo.
Índice
O problema com sinalizadores booleanos e campos opcionais
Um objeto plano que mente para você
Considere um padrão comum: um objeto de resposta com formato { success: boolean; data?: string() }onde data existe apenas quando success é true. O TypeScript não pode impor esse relacionamento. O sistema de tipos vê data como string() | undefined independentemente do valor successporque ambos os campos pertencem a uma única interface. Nada impede que um chamador construa { success: true } sem dataou { success: false, data: ('oops') }.
interface ApiResponse {
success: boolean;
data?: string();
}
function processResponse(res: ApiResponse) {
if (res.success) {
console.log(res.data!.join(', '));
}
}
try {
processResponse({ success: true });
} catch (e) {
console.error('Caught expected crash:', (e as Error).message);
}
A afirmação não nula esconde o problema real. O tipo permite um estado que a lógica de negócios considera impossível e o compilador não pode ajudar.
Por que if (obj.b) Não restringe campos opcionais
O estreitamento do fluxo de controle no TypeScript opera no tipo da propriedade que está sendo verificada, não na forma do objeto envolvente. Quando o tipo é uma interface única em vez de uma união, verificando if (res.success) estreita res.success de boolean para true dentro desse ramo, mas não altera o tipo de res em si. A forma do objeto permanece ApiResponsee data fica string() | undefined. Para restringir a forma de um objeto, em primeiro lugar, seu tipo deve ser uma união de tipos distintos. Sem essa união, o compilador não tem nada a que se restringir.
Sindicatos discriminados: o padrão central
Substituindo a bandeira por discriminantes literais
As uniões discriminadas combinam vários tipos de objetos que compartilham uma propriedade comum cujo tipo é uma string, um número ou um literal booleano exclusivo em cada variante. Essa propriedade compartilhada é chamada de propriedade discriminante. O padrão também é conhecido como união marcada ou tipo de dados algébrico. Quando o código ramifica no discriminante, o TypeScript restringe o objeto inteiro à variante específica, disponibilizando todos os campos dessa variante sem asserções.
type ApiResponse =
| { success: true; data: string() }
| { success: false };
function processResponse(res: ApiResponse) {
switch (res.success) {
case true:
console.log(res.data.join(', '));
break;
case false:
console.log('Request failed');
break;
}
}
O compilador rejeita totalmente o estado impossível. Se
successétrue,datadeve estar presente. Sesuccesséfalsefornecendodatacausa um erro de tipo.
Construindo um Union Member a partir de uma variável booleana
Um problema de construção diferente aparece quando um membro do sindicato é construído a partir de um ambiente de execução boolean. Uma variável digitada boolean é a união true | falsee o objeto { b: flag, c: value } tem que caber em um membro de Result abaixo. Antes do TypeScript 3.5, o compilador rejeitava essa atribuição (confirmei o erro no 3.4.5). Desde 3.5 ele aceita quando o discriminante é um boolean ou outra pequena união de literais, portanto, em um compilador atual, o código no comentário abaixo é compilado.
Essa aceitação é uma conveniência, não uma garantia de que o objeto seja válido. O objeto aceito ainda pode carregar c quando b é false. Construir cada variante em seu próprio branch mantém os dados honestos e funciona em todas as versões. Se você oferecer suporte a compiladores mais antigos, também verá o erro antigo nas respostas existentes do Stack Overflow.
type Result =
| { b: true; c: number }
| { b: false };
function makeResultFixed(flag: boolean, value: number): Result {
if (flag) {
return { b: true, c: value };
} else {
return { b: false };
}
}
function createResult<B extends true | false>(
b: B,
...args: B extends true ? (c: number) : ()
): Result {
if (b) {
const c = (args as (number))(0);
if (typeof c !== 'number') {
throw new Error('createResult: c is required when b is true');
}
return { b: true, c };
}
return { b: false };
}
A primeira opção cria cada variante em seu próprio branch. Cada ramificação retorna um literal de objeto que corresponde exatamente a um membro da união, então c só existe quando b é true. A segunda opção usa um parâmetro de tipo genérico restrito a true | falsepermitindo que o compilador resolva a ramificação correta com base no tipo literal do argumento em cada site de chamada. Um protetor de tempo de execução protege contra chamadores que passam por uma área ampliada boolean valor, que pode ignorar a garantia do tipo de descanso condicional em tempo de compilação (um booleanO argumento digitado torna o tipo restante (c: number) | ()).
Estreitamento na prática: switch, ine fluxo de controle
switch Declarações e discriminantes literais
switch em uma propriedade discriminante restringe a união a exatamente uma variante por case bloquear. Cada case branch vê apenas os campos definidos para essa variante. Enquanto if/else if cadeias funcionam de forma idêntica para fins de estreitamento, switch é a escolha convencional para uniões marcadas porque mapeia uma ramificação por variante e se integra naturalmente à verificação de exaustividade por meio de um default cláusula.
O in Operador como discriminante alternativo
Quando uma propriedade única existe apenas em uma variante, o in operador pode restringir sem um campo discriminante dedicado. Verificando if ('data' in obj) restringe-se à variante que contém data. No entanto, esta abordagem é menos explícita, mais difícil de esgotar sistematicamente e quebra quando duas variantes partilham um nome de propriedade, uma vez que o in check não consegue mais distinguir entre eles. Use-o para complementar, e não substituir, discriminantes literais.
type CSVFieldResult =
| { kind: 'number'; value: number }
| { kind: 'nan'; raw: string };
function parseCSVField(raw: string): CSVFieldResult {
const parsed = Number(raw);
if (Number.isNaN(parsed)) {
return { kind: 'nan', raw };
}
return { kind: 'number', value: parsed };
}
const result = parseCSVField('42abc');
if (result.kind === 'number') {
console.log(result.value);
} else {
console.log('Not a number:', result.raw);
}
Este exemplo demonstra o estreitamento da união discriminada fora dos padrões de configuração de objeto. Ele usa Number() em vez de parseInt para rejeitar corretamente strings com caracteres não numéricos à direita ('42abc' → NaN) e para preservar valores decimais ('3.14' → 3.14). O tipo de retorno discriminado força os chamadores a lidar explicitamente com ambos os resultados.
Verificação exaustiva com o never Tipo
O que é exaustividade e por que é importante
Exaustividade significa que a lógica de ramificação lida com todos os membros de uma união. Quando uma nova variante é adicionada à união, o compilador sinaliza todos os casos não tratados. Sem ser exaustivo, a adição de uma variante passa silenciosamente pelas ramificações existentes, normalmente em um default caso que foi escrito para um conjunto diferente de estados, produzindo erros de tempo de execução que surgem somente depois que os refatoradores atingem a produção.
O assertNever Padrão
A técnica da exaustividade canônica coloca uma assertNever função no default ramo de um switch. Se todas as variantes forem tratadas, o tipo restante em default é never. Se uma variante for perdida, o tipo não será nevere a atribuição ao never parâmetro falha em tempo de compilação. Em uma base de código TypeScript pura, esta linha está inacessível em tempo de execução; ele protege contra chamadores de JavaScript ou asserções de tipo inseguro que ignoram a verificação em tempo de compilação.
export function assertNever(x: never): never {
let description: string;
try {
description = JSON.stringify(x);
} catch {
description = String(x);
}
throw new Error(`Unexpected value: ${description}`);
}
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * (shape.radius ** 2);
case 'square': return shape.side ** 2;
default: return assertNever(shape);
}
}
O erro aparece no momento em que o código é compilado, antes de qualquer teste ser executado. Adicionando
case 'triangle'resolve e restaura a exaustividade.
Construindo uma máquina de estado de formulário de várias etapas
Definindo a União Estadual
UM FormState digite com um kind o discriminante modela cada etapa como uma variante distinta. Cada variante carrega apenas os dados relevantes para aquela etapa, tornando irrepresentáveis combinações impossíveis. O FormState type usa um alias de tipo recursivo (suportado desde TypeScript 3.7) para permitir o error variante para referência FormState em si.
type FormState =
| { kind: 'step1'; name: string; email: string }
| {
kind: 'step2';
step1Data: { name: string; email: string };
address: string;
city: string;
}
| {
kind: 'submitting';
step1Data: { name: string; email: string };
step2Data: { address: string; city: string };
}
| {
kind: 'done';
confirmationId: string;
}
| {
kind: 'error';
message: string;
lastState: Exclude<FormState, { kind: 'error' }>;
};
O step2 variante inclui validado step1Datagarantir que alcançar o segundo passo sem completar o primeiro é estruturalmente impossível. O error variante carrega lastState para lógica de nova tentativa, mas exclui erros aninhados por meio Exclude.
Definindo Ações como uma União Discriminada
As ações modelam as transições entre estados. Cada ação é seu próprio membro do sindicato marcado. O NEXT A ação é dividida em variantes específicas da etapa para que cada transição carregue apenas os dados relevantes para a etapa de origem, evitando cargas úteis estruturalmente incompletas.
type FormAction =
| { type: 'NEXT_STEP1'; name: string; email: string }
| { type: 'NEXT_STEP2'; address: string; city: string }
| { type: 'SUBMIT' }
| { type: 'SUBMIT_SUCCESS'; confirmationId: string }
| { type: 'SUBMIT_FAILURE'; message: string }
| { type: 'RETRY' }
| { type: 'RESET' };
O Redutor Exaustivo
O redutor usa aninhados switch declarações. O switch externo é despachado state.kindo interior em action.type onde múltiplas ações se aplicam a um único estado. Ambos os níveis usam assertNever para garantir a exaustividade.
Cada interior switch deve enumerar todos os membros de FormActionseja como uma transição controlada ou como uma operação autônoma explícita, para o default filial para ver never. Essa verbosidade é o custo do padrão e se paga: adicionar um novo tipo de ação ao FormAction produz erros de compilação em cada switch interno que não o lista.
import { assertNever } from './shared/assertNever';
const INITIAL_STATE: FormState = { kind: 'step1', name: '', email: '' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (state.kind) {
case 'step1':
switch (action.type) {
case 'NEXT_STEP1':
return {
kind: 'step2',
step1Data: { name: action.name, email: action.email },
address: '',
city: '',
};
case 'RESET': return INITIAL_STATE;
case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
return state;
default: return assertNever(action);
}
case 'step2':
switch (action.type) {
case 'NEXT_STEP2':
return {
kind: 'step2',
step1Data: state.step1Data,
address: action.address,
city: action.city,
};
case 'SUBMIT':
return {
kind: 'submitting',
step1Data: state.step1Data,
step2Data: { address: state.address, city: state.city },
};
case 'RESET': return INITIAL_STATE;
case 'NEXT_STEP1': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
return state;
default: return assertNever(action);
}
case 'submitting':
switch (action.type) {
case 'SUBMIT_SUCCESS': return { kind: 'done', confirmationId: action.confirmationId };
case 'SUBMIT_FAILURE': return { kind: 'error', message: action.message, lastState: state };
case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'RETRY': case 'RESET':
return state;
default: return assertNever(action);
}
case 'done':
switch (action.type) {
case 'RESET': return INITIAL_STATE;
case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
return state;
default: return assertNever(action);
}
case 'error':
switch (action.type) {
case 'RETRY': return state.lastState;
case 'RESET': return INITIAL_STATE;
case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE':
return state;
default: return assertNever(action);
}
default: return assertNever(state);
}
}
Por que isso supera uma abordagem de sinalizadores booleanos
Uma alternativa plana como { step: number; isSubmitting: boolean; error?: string; data?: object } permite estados que nunca deveriam existir: isSubmitting: true com error definido simultaneamente, step: 3 sem dados das etapas um ou dois, ou error preenchido enquanto isSubmitting restos true. Com o tipo plano, você escreve guardas como if (state.isSubmitting && !state.error) em todos os locais de consumo. O sindicato discriminado elimina estruturalmente esses guardas. Adicionar um sexto estado à união aciona erros de compilação em todos os lugares em que o redutor e qualquer código de consumo falha em tratá-lo.
Comparação: ?: never vs. Sindicatos Discriminados
| Critério | ?: never Padrão |
União Discriminada |
|---|---|---|
| Restringindo a ergonomia | Requer in verifica ou digita predicados; nenhuma propriedade única para ativar |
switch (obj.kind) estreita instantaneamente |
| Erros de exaustividade | Nenhum mecanismo integrado; verificações manuais necessárias | assertNever em default dá erros em tempo de compilação |
| Gravações covariantes incorretas | Potencialmente incorreto: a subtipagem estrutural pode permitir que uma variável de tipo mais amplo contorne a restrição de campo exclusivo; você ainda deve validar em tempo de execução | Cada variante é um tipo distinto; gravações covariantes não mesclam formas silenciosamente |
| É necessário discriminante explícito? | Não. Depende da ausência estrutural de propriedades | Sim. Requer uma propriedade literal compartilhada em todas as variantes |
| Melhor caso de uso | Objetos pequenos e de duas variantes onde adicionar um kind campo é impraticável |
Três ou mais variantes, máquinas de estado, redutores, qualquer caminho de código que exija exaustividade |
O ?: never padrão funciona marcando uma propriedade como opcional com tipo nevero que evita qualquer nãoundefined valor de ser atribuído; undefined permanece atribuível, a menos que exactOptionalPropertyTypes: true está definido em tsconfig.json. É útil para restrições leves de campo exclusivo, mas não oferece nenhuma das garantias restritivas ou exaustivas que os sindicatos discriminados oferecem.
Diretrizes Práticas e Armadilhas
Quando alcançar sindicatos discriminados
Os sindicatos discriminados enquadram-se bem nestes cenários:
- Três ou mais estados que compartilham uma forma comum
- Qualquer máquina de estado ou fluxo de trabalho com transições definidas
- Tipos de resposta de API com formatos distintos por resultado
- Padrões redutores onde as transições devem ser tratadas exaustivamente
As uniões de duas variantes também obtêm exaustividade e estreitamento em tempo de compilação do padrão, como o ApiResponse exemplo anterior demonstra.
Erros Comuns
Usando string em vez de uma string literal para o discriminante derrota o estreitamento total. Com kind: string em cada variante, switch (x.kind) não restringe a união, portanto, a leitura de um campo somente variante, como x.a dentro case 'a' falha com o erro TS2339 (a propriedade não existe na união).
Construindo membros do sindicato a partir de um tempo de execução boolean sem ramificação pode produzir objetos que carregam campos que a outra variante não deveria ter, mesmo quando o compilador a aceita (veja acima).
Aninhar uniões com mais de um nível de profundidade (uma união discriminada dentro de outra união discriminada) complica o estreitamento e torna os redutores mais difíceis de ler. Prefira discriminantes planos com descritivos kind valores como 'step2-loading' sobre estruturas aninhadas.
Próximas etapas
Sinalizadores booleanos escondem estados impossíveis por trás de suposições de tempo de execução. Os sindicatos discriminados revelam esses estados em tempo de compilação e a verificação exaustiva com
assertNevergarante que futuras adições à união não possam escapar silenciosamente pelos caminhos de código existentes.
Encontre um tipo de sinalizador booleano em sua base de código, refatore-o em uma união discriminada e deixe o compilador revelar todos os locais que presumiam que um estado impossível era seguro.
Source link




