Uma propriedade opcional em TypeScript parece simples: escreva timeoutMs?: number e siga em frente. O problema aparece quando um objeto é montado com spreads ou quando uma API interpreta "chave ausente" como "use o padrão". Nesse caso, {} e { timeoutMs: undefined } não são o mesmo objeto.

Com exactOptionalPropertyTypes: true, o compilador deixa essa diferença explícita. timeoutMs?: number permite omitir a chave ou enviar um número. Se o contrato também aceita uma chave presente com undefined, escreva timeoutMs?: number | undefined. A opção ajuda quando a presença da chave tem significado, mas não substitui validação em runtime.

Este artigo é um spoke de validação de tool calls em TypeScript. O owner trata dados externos que chegam para uma tool. Aqui, a pergunta é anterior: qual objeto o código pode construir e qual significado cada estado deve ter?

Diagrama mostra uma propriedade opcional se dividindo entre chave ausente, undefined presente e uma verificação de contrato.

Resposta curta

  • Use prop?: T quando a chave puder ser omitida e, quando presente, precisar conter T.
  • Use prop?: T | undefined quando uma chave presente com undefined for um estado válido.
  • Ative exactOptionalPropertyTypes quando o código distingue ausência, undefined e, talvez, null.
  • Revise objetos construídos com spread: passar undefined para representar "não informado" pode deixar uma chave presente.
  • Continue validando JSON, respostas HTTP e argumentos de tools em runtime.

O que exactOptionalPropertyTypes muda?

Sem a opção, o TypeScript trata uma propriedade opcional como se o tipo também aceitasse undefined em muitas atribuições. Assim, este código costuma compilar:

interface UserPreferences {
  colorTheme?: "dark" | "light";
}

const preferences: UserPreferences = {
  colorTheme: undefined,
};

Com "exactOptionalPropertyTypes": true, a declaração é lida de forma mais literal. colorTheme?: "dark" | "light" significa que a chave pode não existir ou pode conter uma das duas strings. Atribuir undefined passa a ser um erro, a menos que undefined apareça explicitamente no tipo.

A referência do TSConfig para exactOptionalPropertyTypes usa o mesmo contraste: a ausência representa um estado, enquanto uma chave com undefined continua sendo uma chave. As notas do TypeScript 4.4 também explicam que a opção não faz parte da família strict e depende de strictNullChecks.

Isso muda principalmente o que o código pode escrever. Ao ler preferences.colorTheme, você ainda pode obter "dark" | "light" | undefined, porque a chave pode estar ausente. A opção não faz a leitura deixar de ser opcional.

Por que a chave ausente é diferente de undefined?

JavaScript permite representar os dois estados:

const omitted = {};
const present = { timeoutMs: undefined };

console.log("timeoutMs" in omitted); // false
console.log("timeoutMs" in present); // true
console.log(Object.keys(omitted)); // []
console.log(Object.keys(present)); // ["timeoutMs"]

O operador in verifica se a propriedade existe no objeto ou na cadeia de protótipos. A documentação do MDN também destaca que uma propriedade pode existir e ter valor undefined; por isso, "x" in object não é a mesma coisa que object.x !== undefined.

Essa diferença aparece em código que mescla opções. Imagine um executor que mantém um padrão quando timeoutMs não foi informado:

type ExecutorOptions = {
  timeoutMs?: number;
};

const defaults = { timeoutMs: 10_000 };
const incoming: ExecutorOptions = { timeoutMs: undefined };

const options = { ...defaults, ...incoming };
console.log(options.timeoutMs); // undefined

O spread não sabe que undefined queria dizer "não altere o padrão". Ele copia a propriedade presente. Object.assign() segue a mesma ideia: copia propriedades próprias enumeráveis e as fontes posteriores sobrescrevem chaves anteriores (MDN, Object.assign()).

O TypeScript FAQ usa um exemplo semelhante para mostrar por que Partial<T> com valores undefined pode ser suspeito quando combinado com spread (TypeScript FAQ). Isso não torna todo spread errado. O tipo ainda precisa dizer se a presença da chave importa.

Como modelar configurações e opções?

Comece escrevendo o contrato antes de ativar a flag. Há pelo menos três intenções diferentes.

A chave pode ser omitida, mas não pode ser undefined

Use uma propriedade opcional comum quando o chamador pode deixar o valor de fora:

type ToolOptions = {
  timeoutMs?: number;
  signal?: AbortSignal;
};

const defaults: ToolOptions = {};
const withTimeout: ToolOptions = { timeoutMs: 5_000 };

Com exactOptionalPropertyTypes, const invalid: ToolOptions = { timeoutMs: undefined } falha. Esse erro é útil quando o executor distingue uma opção não fornecida de uma opção explicitamente configurada.

A chave presente com undefined é válida

Algumas APIs usam a presença da chave para padronizar genéricos, compor adapters ou preservar uma assinatura pública. Nesse caso, declare o estado de forma explícita:

type PublicOptions = {
  timeoutMs?: number | undefined;
};

const allowed: PublicOptions = { timeoutMs: undefined };

O ? ainda permite omitir a chave. O | undefined acrescenta a possibilidade de a chave existir com esse valor. Não adicione essa união apenas para silenciar o compilador: ela documenta uma escolha do contrato.

null representa uma operação diferente

Em um payload de atualização, talvez a ausência signifique "não alterar" e null signifique "limpar":

type ProfilePatch = {
  displayName?: string;
  bio?: string | null;
};

const rename: ProfilePatch = { displayName: "Ada" };
const clearBio: ProfilePatch = { bio: null };

Esse significado é uma decisão da sua API. exactOptionalPropertyTypes ajuda a impedir que undefined entre por acidente, mas não escolhe se null deve limpar um campo. Documente a regra e teste os estados aceitos no limite que recebe o payload.

Para um registro de ferramentas escrito no código, use satisfies para conferir a configuração sem perder inferência. São verificações complementares: satisfies confere a forma estática do objeto, enquanto exactOptionalPropertyTypes evita tratar uma chave presente com undefined como se ela estivesse ausente.

Como ativar a opção sem transformar a migração em palpite?

Ative-a junto com strictNullChecks, como no tsconfig.json mínimo abaixo:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "exactOptionalPropertyTypes": true,
    "noEmit": true
  }
}

O comando de verificação pode ser simples:

npx tsc --noEmit

Para proteger um comportamento específico, use um teste de tipo com @ts-expect-error:

type RequestOptions = {
  cache?: "no-store" | "force-cache";
};

const request: RequestOptions = {};
request.cache = "no-store";

// @ts-expect-error: o contrato exige ausência ou um modo conhecido
request.cache = undefined;

Uma migração segura separa quatro perguntas:

  1. O erro apareceu porque o código queria omitir a chave? Construa o objeto condicionalmente.
  2. O erro apareceu porque undefined é um estado público válido? Adicione | undefined à declaração que expressa essa intenção.
  3. O erro veio de uma dependência ou de tipos gerados? Confirme a versão, abra o tipo que originou a incompatibilidade e não esconda o problema com uma asserção global.
  4. O erro veio de um valor externo? Não resolva com uma alteração de interface. Valide o dado antes de convertê-lo em um tipo interno.

Para a primeira pergunta, prefira não inserir a chave quando a condição não existe:

const options: RequestOptions = {
  ...(useCache ? { cache: "force-cache" } : {}),
};

O guia de validação de JSON externo em TypeScript cobre a fronteira diferente: um tipo estático não confirma que o JSON recebido tem a forma declarada.

O que a flag não prova?

exactOptionalPropertyTypes age na checagem de atribuições do TypeScript. Ela não inspeciona uma resposta HTTP, não valida uma mensagem de fila e não impede que any ou um cast incorreto atravesse o programa. Também não garante que a implementação respeite o significado documentado de null.

Em uma chamada de tool, por exemplo, ainda é necessário validar os argumentos recebidos antes da execução. A validação de tool calls em TypeScript trata esse contrato de runtime. O compilador pode impedir que o seu próprio código construa um objeto inválido; ele não pode conferir sozinho um objeto criado fora do processo.

Também existe um custo de compatibilidade. A flag afeta todas as propriedades opcionais do projeto, não apenas uma interface. Tipos públicos, código gerado e dependências podem assumir que prop?: T aceita undefined. A FAQ do TypeScript recomenda entender esse impacto no contexto do projeto. Não há uma configuração por tipo que permita ativar a semântica apenas em uma interface isolada.

Checklist de decisão

Antes de ativar a opção, responda:

  • A ausência da chave significa "usar padrão", "herdar" ou "não alterar"?
  • Uma chave presente com undefined tem algum comportamento diferente?
  • null é o valor de limpeza ou deve ser rejeitado?
  • Algum spread ou Object.assign() copia opções que carregam undefined?
  • As declarações públicas e os tipos gerados aceitam a semântica que você quer?
  • O código ainda valida entradas externas em runtime?
  • Existe um teste de compilação que falha se a distinção voltar a ser apagada?

Se a resposta para a primeira pergunta for sim e undefined não tiver significado próprio, exactOptionalPropertyTypes é uma forma direta de fazer o tipo refletir o contrato. Se a resposta for não, manter a flag desligada pode ser coerente, desde que essa flexibilidade seja intencional e esteja coberta por testes.

Método e limites

Samuel Fajreldines é o responsável por este artigo. A pesquisa comparou a referência atual do TSConfig, as notas do TypeScript 4.4, a FAQ do projeto e a documentação do MDN sobre presença de propriedades e Object.assign(). A fixture mínima foi compilada com uma instalação temporária do TypeScript e o exemplo de presença foi executado com Node.js. Os exemplos demonstram o contrato descrito, mas não são um benchmark de migração nem uma medição de compatibilidade do ecossistema.

Fontes