JSON Toolkit

Por que o JSON Schema gerado automaticamente rejeita dados reais

Resumo: Schemas gerados frequentemente bloqueiam dados válidos. Aprenda por que amostras mais ricas produzem schemas piores e depois o fluxo: amostragem multi-registro, completude semântica, localização de erros.

Intermediário~14 min de leituraPublicado em 2026-10-30Atualizado em 2026-10-30
Capa ilustrando conflitos entre um rascunho de JSON para Schema e dados reais

Resumo da ópera: converja a fronteira obrigatório/opcional à mão primeiro — alimente 5–20 registros de formas diferentes com o "todos obrigatórios" desligado — depois gere o schema e acrescente format, enum e additionalProperties manualmente; uma única amostra com todos os campos sempre será estrita demais.

O gerador só consegue ver o único registro que você colou. Ele lê "este registro tem um e-mail" como "todo registro precisa ter um e-mail", então quanto mais completas suas amostras, mais ele exige.

1. Um gerador faz mapeamento estrutural, não compreensão de negócio

Entregue um pedaço de JSON a um gerador e ele faz exatamente uma coisa: percorre a estrutura dos valores e traduz cada nó em palavras-chave de schema. A tabela de mapeamento inteira é fixa:

Valor na amostra Tipo inferido Observações
"hello" {"type": "string"} Não distingue e-mail, data, UUID
42 {"type": "number"} Um inteiro não é inferido como integer
3.14 {"type": "number"} O mesmo que acima
true {"type": "boolean"} —
null {"type": "null"} Reconhece apenas null, nunca "anulável"
[1, "a"] {"type": "array", "items": {"type": ["number", "string"]}} Tipos de elemento são unidos
{"a": 1} {"type": "object", "properties": {...}, "required": [...]} required é o ponto crucial aqui

Todo o problema está na última linha. required não é derivado de tipos; é derivado de "esta chave existe nesta amostra". Quando a amostra é um único registro, essa afirmação é equivalente a "toda chave é obrigatória".

E os dados retornados por APIs reais quase sempre têm metade dos campos opcionais. É aí que o conflito começa.

2. Cinco registros de mesma forma, cinco taxas de aceitação

Comece pelas medições. Qualquer pessoa olhando os cinco registros abaixo diria "estes têm a mesma forma":

[
  {
    "id": 1,
    "name": "Ada",
    "email": "ada@example.com",
    "tags": ["a"],
    "score": 10
  },
  { "id": 2, "name": "Bob", "tags": [], "score": 7 },
  {
    "id": 3,
    "name": "Cy",
    "email": "cy@example.com",
    "tags": ["a", "b"],
    "score": 8.5
  },
  { "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
  {
    "id": 5,
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["d"],
    "score": 6,
    "admin": true
  }
]

email aparece em 3 dos 5 registros, admin aparece só uma vez, e o registro 2 tem um array tags vazio.

Agora gere um schema a partir de cada registro individualmente e depois use cada resultado para validar todos os cinco registros (validador: ajv 8):

Gerado a partir de Contagem de chaves required gerado Aceita de 5
Registro 1 5 id, name, email, tags, score 3 registros
Registro 2 4 id, name, tags, score 5 registros
Registro 3 5 id, name, email, tags, score 3 registros
Registro 4 5 id, name, email, tags, score 1 registro
Registro 5 6 id, name, email, tags, score, admin 1 registro

Nenhum dos cinco schemas está correto, mas o único que aceita todos os dados vem do registro 2 — o registro ao qual falta email. Ele ganha por um motivo simples: chaves que nunca aparecem nunca são escritas em required.

O registro 4 é ainda mais instrutivo: seu valor de email é null, então o gerador fixa {"type": "null"}, e os outros quatro registros com e-mails string são todos rejeitados.

3. Por que "colar o registro mais completo" é a pior escolha

A intuição é "cole a amostra com mais campos e o schema ficará completo". As medições mostram o exato oposto — e um terceiro executou um teste controlado maior.

A absurdtools usou 3000 "famílias de dados" aleatórias, 8 registros por família, com cada campo opcional presente em cerca de metade das vezes, e mediu quantos dados um schema gerado a partir de amostras com diferentes contagens de chaves conseguia aceitar:

Contagem de chaves da amostra Fração dos dados da família aceita
2 100,0%
3 56,8%
4 34,8%
5 23,4%
6 17,7%
7 14,9%

A conclusão não é "um leve declínio" mas uma inversão completa: a amostra mínima produziu o schema mais frouxo em 95% das famílias, enquanto a amostra mais completa conseguiu isso apenas 0,1% das vezes.

O motivo está na semântica de required, que significa "todos"; a opcionalidade só pode ser observada pela ausência. Colar um registro com todos os campos diz ao gerador "nem uma destas chaves pode ser omitida"; colar um registro com um campo ausente lhe entrega a evidência de que "esta chave pode estar ausente".

Lembre em uma frase: o valor de uma amostra não é a completude, mas quais ausências ela cobre.

Quantos registros bastam

O mesmo lote de experimentos também mediu a velocidade de convergência: acumulando registros um a um sob a regra "uma chave permanece obrigatória apenas se todo registro até aqui a tem", o número mediano de registros para required convergir ao conjunto obrigatório verdadeiro é 3; 6 registros cobrem cerca de noventa por cento das famílias, e 12 cobrem todas.

Então amostrar 5–10 registros no dia a dia é o melhor custo-benefício; forçar vinte ou mais rende muito pouco extra.

Mas há uma pré-condição: esses registros precisam vir de situações genuinamente diferentes. Cinco registros copiados do mesmo template param de fornecer qualquer informação nova a partir do segundo — seus conjuntos de campos são idênticos, e required não consegue estreitar uma única chave.

4. A correção: alimente vários registros de uma vez e deixe as ausências falarem

Como a amostragem de registro único é necessariamente estrita demais, a abordagem certa é deixar o gerador olhar vários registros de uma vez e convergir required com a regra "obrigatório apenas se todo registro o tiver".

O gerador deste site aceita um array diretamente e tem um botão "todos obrigatórios". Desligue-o e ele decide pela contagem de ocorrências: só chaves carregadas por todo registro são escritas em required.

Ainda aqueles cinco registros — desta vez combine-os em um array e infira juntos, depois tome items como o contrato do elemento:

Registros alimentados required inferido Validação dos 5
1 registro id, name, email, tags, score 3 passam
2 registros id, name, tags, score 4 passam
3 registros id, name, tags, score 4 passam
4 registros id, name, tags, score 5 passam
5 registros id, name, tags, score 5 passam

required converge assim que o quarto registro é adicionado, e não muda depois. email e admin são corretamente colocados em properties mas não em required. E como o e-mail do registro 4 é null, a inferência multi-registro o combina em {"type": ["string", "null"]}, então tanto strings quanto null passam — algo que a amostragem de registro único nunca consegue fazer.

É a mesma ordem de magnitude da constatação da absurdtools de que "required converge após uma mediana de 3 registros".

A inferência multi-registro tem seus próprios pontos cegos, porém: se o email nestes registros por acaso for null em todos (digamos, todos vêm de contas sem perfil preenchido), o resultado da inferência vira {"type": "null"}, e e-mails string continuam rejeitados. Do mesmo modo, se todo valor de tags for um array vazio, o tipo do elemento não pode ser inferido de forma alguma.

Então a amostragem tem mais um requisito rígido: toda forma de todo campo precisa aparecer pelo menos uma vez. Traga uma string, um número, um null e um array vazio; não deixe nenhuma forma totalmente ausente.

Concretamente:

  1. Extraia 5–20 respostas de logs, mocks ou requisições reais, e inclua as linhas anômalas (a com campos vazios, a com null, a com array vazio);
  2. Monte-as em um array JSON e cole em o gerador;
  3. Desligue "todos obrigatórios" e escolha o draft 2020-12;
  4. O resultado é type: array mais um items; tome a seção items como o contrato de um único registro;
  5. Valide imediatamente as amostras originais com este schema — 100% precisa passar. Se até uma falhar, sua amostragem não basta; volte e acrescente registros.

O passo 5 é a autoverificação mais barata. Se o gerador não consegue acomodar nem os dados que você alimentou, seu método de amostragem está errado, e não adianta ir adiante.

5. Quatro coisas que um gerador não preenche — você precisa escrevê-las à mão

Mesmo após alimentar vários registros, o seguinte ainda não pode ser inferido e precisa ser adicionado manualmente:

Peça faltante Comportamento medido Consequência Como corrigir
Formato de string "a@b.com" produz apenas {"type": "string"} Validar "not an email" passa Acrescente "format": "email" e habilite asserções de formato
Faixas e enums Não consegue ver que score é limitado a 0–100, nem que status tem apenas três valores Números negativos, valores enormes e valores de enum lixo todos passam Acrescente minimum / maximum / enum
Política de campo desconhecido additionalProperties não é gerado Uma chave mal escrita é aceita silenciosamente, o que é mais perigoso que um erro Acrescente "additionalProperties": false para arquivos no estilo configuração
Semântica de anulabilidade e coleção vazia Uma única amostra contendo null trava em {"type": "null"}; um array vazio produz "items": {} Valores válidos são rejeitados, ou elementos de array ficam completamente sem restrição Mude para ["string", "null"], e dê elementos reais a arrays vazios

A terceira linha merece menção separada: o valor padrão de additionalProperties é "permitir". Isso significa que um arquivo de configuração que escreve timeout como timeuot passa na validação e depois não faz nada silenciosamente em tempo de execução. Para schemas no estilo de configuração, este é um item que você precisa explicitamente desligar.

format também tem uma armadilha: desde o draft 2019-09, format pertence ao vocabulário de anotação, e por padrão ele apenas anota em vez de impor. Se você acrescentou format: "email" e valores ilegais ainda passam, em oito de cada dez casos o validador não habilitou validateFormats ou o plugin de formato correspondente — não é o schema que está errado.

Uma nota sobre a escolha de draft, já que estamos aqui: se o schema vai entrar no OpenAPI 3.1, ele precisa ser draft 2020-12, porque a 3.1 embute essa versão diretamente; cadeias de ferramentas mais antigas (o ecossistema Swagger 2.0 e algumas implementações de $ref) ainda se centram no draft-07. Os dois se comportam de forma diferente para palavras-chave irmãs de $ref e para palavras-chave de array, então não os misture dentro de um conjunto de schemas.

6. Para APIs de terceiros sem documentação, faça engenharia reversa do contrato a partir de amostras de resposta

Integrar um callback de terceiros, trabalhar com um parceiro que não escreve documentação ou manter um sistema legado de dez anos — tudo isso significa "os dados estão em mãos, a especificação não". Aqui um gerador não é apenas uma conveniência; é o caminho mais rápido para um contrato.

O método é o mesmo acima, com dois requisitos extras:

Um: amostre ao longo do tempo e dos estados. Campos de terceiros frequentemente mudam com o estado do negócio — uma promoção acrescenta um discount, um pedido de reembolso acrescenta um refundAt, e uma resposta de erro não tem data de forma alguma. Pegar um único snapshot significa ver apenas um estado.

Alimente alguns sucessos, algumas falhas e alguns resultados vazios juntos: error e data frequentemente aparecem mutuamente exclusivos, e a inferência multi-registro coloca ambos em properties enquanto os deixa fora de required — correspondendo exatamente à realidade.

Dois: armazene o schema no repositório e anexe uma checagem diária. Valide as respostas reais de hoje contra ele, e você saberá de campos desaparecendo silenciosamente ou mudando de tipo antes dos seus usuários.

// Rode uma vez por dia: alerta de desvio de contrato
const drifted = records.filter((record) => !validate(record));

if (drifted.length > 0) {
  console.warn(
    `${drifted.length} responses do not match the contract; first difference:`,
    validate.errors,
  );
}

O valor desta prática não é "economizar o esforço de escrever à mão", mas transformar um acordo verbal em um alerta executável. Um terceiro nunca avisa de antemão que mudou um campo, mas a mudança certamente aparecerá nos seus logs.

Uma fronteira a traçar claramente: o schema de engenharia reversa descreve o que a outra parte retorna agora, não o que ela promete retornar. O primeiro é para monitoramento; só o segundo pertence a um contrato ou a documentação de API.

7. Após a completude, faça os erros de validação apontarem de volta para campos específicos

Uma vez que o schema é utilizável, a próxima preocupação são erros legíveis. Um objeto de erro do ajv tem cinco campos, e os quatro primeiros são os úteis:

Campo Significado Exemplo (id: 0 violando minimum: 1)
instancePath JSON Pointer para o valor problemático nos dados "/id"
schemaPath Localização da regra disparadora no schema "#/properties/id/minimum"
keyword Nome da restrição que falhou "minimum"
params Detalhes legíveis por máquina dessa restrição { "comparison": ">=", "limit": 1 }
message Prosa em inglês que muda entre versões, não a analise "must be >= 1"

Uma assimetria é a mais fácil de tropeçar: quando required falha, instancePath aponta para o objeto pai (normalmente a string vazia, ou seja, a raiz), não para o campo ausente. O nome da chave ausente está em params.missingProperty. Do mesmo modo, quando additionalProperties falha, o nome da chave extra está em params.additionalProperty.

Então, ao mapear erros para "campo → mensagem", trate primeiro estes dois casos especiais:

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

/** Converte o array de erros do ajv em { campoCaminho: [mensagem] } para destaque de formulário */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // Para required e additionalProperties, o campo problemático vive em params, não em instancePath
    const field =
      error.keyword === "required"
        ? error.params.missingProperty
        : error.keyword === "additionalProperties"
          ? error.params.additionalProperty
          : error.instancePath.replace(/^\//u, "").split("/").join(".") ||
            "(root)";

    (grouped[field] ??= []).push(error.message);
  }

  return grouped;
}

if (!validate(data)) console.log(groupByField(validate.errors));

allErrors: true não é opcional. Na configuração padrão, o ajv para no primeiro erro, então o usuário corrige um campo, reenvia, recebe o próximo erro, e precisa de três idas e voltas só para preencher um formulário. Se você também usa ajv-errors para mensagens customizadas, sem esta flag o plugin substituirá silenciosamente apenas algumas das mensagens.

8. Um processo de implantação reutilizável

Amarre os passos acima em uma rotina fixa e siga-a toda vez que integrar uma nova API:

  1. Amostre: pegue 5–20 respostas reais, escolhendo deliberadamente as com campos vazios, nulls, arrays vazios e campos extras;
  2. Infira: cole-as juntas como um array em o gerador, desligue "todos obrigatórios" e tome items;
  3. Complete a semântica: acrescente format, enum, minimum/maximum, minItems e additionalProperties um item por vez;
  4. Autoverifique: as amostras originais precisam passar 100%, caso contrário volte ao passo 1 e acrescente amostras;
  5. Prova negativa: prepare uma entrada "deve falhar" para cada restrição e confirme que ela realmente falha — testar apenas o caminho de sucesso é o mesmo que não testar;
  6. Commite: coloque o schema no controle de versão, mantenha as amostras que falham como casos de teste, e rode os testes primeiro sempre que a API mudar de campo.

O teste negativo do passo 5 é o mais fácil de pular. Acrescentar uma chave a required sem nunca verificar que uma chave ausente gera erro significa apostar que o validador concorda com seu modelo mental.

import { describe, expect, it } from "vitest";

describe("user schema", () => {
  it("accepts a complete record", () => {
    expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
  });

  it("rejects a record without name", () => {
    expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
    // Assegure também o motivo, para o teste não passar porque outro campo falhou
    expect(
      validate.errors?.some((e) => e.params?.missingProperty === "name"),
    ).toBe(true);
  });
});

Essa última asserção importa. Assegurar apenas false pode permanecer verde mesmo depois que o schema foi quebrado — a falha pode vir de uma causa totalmente diferente nesse momento.

9. Perguntas frequentes

O schema que um gerador produz pode ir direto para produção

Não. Ele é apenas um snapshot da estrutura. Você precisa no mínimo acrescentar formatos, faixas, enums e uma política de campo desconhecido, e depois rodar tanto as amostras originais quanto as amostras negativas por ele. O gerador resolve "digitar properties à mão é tedioso", não "ele decide as regras de negócio".

Para o sistema de palavras-chave da autoria de schemas, divisão com $ref e palavras-chave de composição, veja JSON Schema: do básico à produção.

Por que o registro com mais campos rejeita mais dados

Porque required é coletado a partir de "o que apareceu na amostra". Uma amostra com todos os campos transforma toda chave opcional em obrigatória, enquanto a maioria dessas chaves está ausente nos dados reais. Alimentar vários registros, ou um registro que omite campos deliberadamente, leva você mais perto da verdade do que a amostra mais completa.

Um inteiro será inferido como integer

O gerador deste site não faz isso: tanto inteiros quanto floats são emitidos como number, então um schema gerado a partir de {"price": 5} aceita 5.99. Isso evita acidentes como "a amostra por acaso era um inteiro, então a produção rejeita preços com decimais". Algumas ferramentas de terceiros inferem integer a partir do valor, então tenha especialmente cuidado com campos de dinheiro.

Para detalhes sobre precisão numérica e fronteiras de tipo, veja Os seis tipos de dados JSON e armadilhas de precisão numérica.

Acrescentei format mas não tem efeito

Desde o draft 2019-09, format é apenas anotação por padrão. Ou habilite explicitamente as asserções de formato no validador (o ajv precisa de ajv-formats), ou use pattern para uma restrição rígida. Não presuma que acrescentá-lo bloqueará algo.

required relata um erro, mas o campo está claramente presente nos dados

Olhe primeiro o instancePath. Um erro de required aponta para o objeto pai, o que significa que a chave ausente não está naquele nível — causas comuns são um nível de aninhamento errado (/profile/name não é o mesmo que name na raiz), diferença de maiúsculas/minúsculas (userId versus userID), ou a serialização upstream descartando undefined por completo.

Antes de investigar, confirme que o documento em si é JSON válido: comentários, vírgulas finais e problemas semelhantes falham na etapa de análise, antes que o schema tenha qualquer chance. Análise e formatação JSON aponta diretamente para a linha problemática.

Quantos registros devo amostrar e qual draft devo usar?

Amostrar 5–10 registros no dia a dia dá o melhor custo-benefício; required converge com uma mediana de 3 registros, e 12 registros cobrem todas as famílias. Cada registro precisa vir de uma situação genuinamente diferente, e toda forma de todo campo precisa aparecer pelo menos uma vez. Fixe o draft em 2020-12, porque o OpenAPI 3.1 aceita apenas essa versão.

Por que um additionalProperties permissivo por padrão é mais perigoso?

O padrão não emite palavra-chave alguma, então uma chave mal escrita é aceita silenciosamente, passa na validação e depois falha em tempo de execução — mais difícil de diagnosticar que um erro direto. Schemas no estilo de configuração precisam definir explicitamente "additionalProperties": false, enquanto schemas de API devem primeiro pesar o custo de compatibilidade.

Devo usar um schema de engenharia reversa como gate rígido?

Não. Um schema de engenharia reversa descreve o que a outra parte retorna agora, não o que ela promete retornar, então ele pertence ao monitoramento e a alertas de desvio. O que entra em um contrato ou em documentação de API precisa ser o contrato prometido.