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.

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:
- 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);
- Monte-as em um array JSON e cole em o gerador;
- Desligue "todos obrigatórios" e escolha o draft 2020-12;
- O resultado é
type: arraymais umitems; tome a seçãoitemscomo o contrato de um único registro; - 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:
- Amostre: pegue 5–20 respostas reais, escolhendo deliberadamente as com campos vazios, nulls, arrays vazios e campos extras;
- Infira: cole-as juntas como um array em o gerador, desligue "todos obrigatórios" e tome
items; - Complete a semântica: acrescente
format,enum,minimum/maximum,minItemseadditionalPropertiesum item por vez; - Autoverifique: as amostras originais precisam passar 100%, caso contrário volte ao passo 1 e acrescente amostras;
- 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;
- 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.