JSON Toolkit

Reutilizando JSON Schema: $ref, $defs e composição com allOf/oneOf

Resumo: `$ref` resolve reutilização, `allOf` e `oneOf` resolvem composição, e empilhar os dois é onde as coisas quebram. Cobre `$id`, `$defs`, condicionais, recursão, empacotamento e depuração.

Intermediário~11 min de leituraPublicado em 2026-10-31Atualizado em 2026-10-31
Diagrama da reutilização e composição de JSON Schema com $ref, $defs e allOf/oneOf

Resumo da ópera: extraia estruturas compartilhadas em $defs com $ref primeiro e depois recorra a allOf/oneOf; quando os ramos puderem corresponder ao mesmo tempo, use anyOf ou uma tag const, e reserve oneOf para casos que realmente exigem exatamente uma correspondência.

Copiar e colar economiza dez minutos e lhe custa uma bifurcação silenciosa três meses depois, quando ninguém lembra qual cópia está correta.

1. Três camadas de reutilização — escolha uma antes de dividir arquivos

O primeiro instinto, quando os campos começam a se multiplicar, é enfiar toda restrição em um único arquivo. É o jeito mais rápido de entregar, mas depois de algumas centenas de linhas toda mudança de campo precisa ser espelhada em três lugares. Erre um e você terá "passa localmente, retorna 500 em produção". Reutilização significa escrever um fato uma vez e referenciá-lo em todo o resto.

O JSON Schema oferece três camadas, cada uma com um escopo diferente:

Camada Portador Escopo Uso típico
Subestrutura $defs + $ref Um arquivo de schema Endereço, dinheiro, paginação, formas de erro
Arquivo Schema autônomo + $id Vários arquivos em um serviço Entidades de usuário, pedido, produto
Biblioteca de contrato Repositório de schemas + artefato empacotado Múltiplos serviços, múltiplas linguagens Componentes OpenAPI, contratos publicados

Para a maioria dos projetos a primeira camada basta. Dividir em arquivos só compensa quando dois serviços genuinamente compartilham o mesmo contrato, ou quando o schema é um artefato publicado consumido por outra linguagem. Dividir cedo demais torna a resolução de $id e caminhos relativos mais difíceis de depurar, e esse custo costuma exceder o que você economizou evitando duplicação.

Uma regra simples: se uma estrutura aparece em dois lugares, uma cópia é tolerável; quando aparece em um terceiro, promova-a para $defs; só quando outra linguagem ou repositório precisa lê-la ela merece um arquivo autônomo com seu próprio $id.

2. $id estabelece identidade, $ref executa o salto

$id não é um comentário. Ele declara a URI base do schema. Um valor $ref é uma referência de URI: ele é resolvido contra a URI base atual em uma URI absoluta, e depois o fragmento após # localiza um nó. Se a URI base está errada, toda referência relativa aponta para outro lugar, embora o erro normalmente só diga "can't resolve reference" sem nomear o host errado.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

shipTo usa um JSON Pointer enquanto billTo usa uma âncora; ambos caem no mesmo nó. A tabela abaixo mapeia cada forma para sua resolução:

Forma Aponta para Draft Observações
# Raiz do schema atual Todos Comum para recursão
#/$defs/address Nó sob $defs 2019-09 e posteriores O mais claro; preferido
#address Âncora nomeada $anchor no 2020-12 O draft-07 escrevia $id: "#address"
common.json Arquivo relativo à URI base Todos Precisa ser registrado ou empacotado
https://example.com/x.json URI absoluta Todos Evite buscar em tempo de execução em produção

Uma armadilha sutil: se um sub-schema dentro de $defs declara seu próprio $id, ele se torna a nova URI base para tudo abaixo dele. Cole um schema copiado de outro arquivo e o $id remanescente redirecionará suas referências relativas para o host original — e o validador só percebe quando realmente tenta resolvê-las.

3. $defs é um contêiner, não um local de validação

$defs é um mapa comum. Nada dentro dele entra em vigor até que um $ref aponte para lá. Uma lista required no topo de $defs sem nenhuma referência recebida não valida absolutamente nada, por mais estrita que pareça. Isso é a especificação funcionando como pretendido, não um bug do validador.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "Uma string monetária com duas casas decimais que evita desvio de ponto flutuante"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

Algumas convenções vale fixar na equipe:

  • Chaves dentro de $defs não carregam semântica. address, Address e addr são todas legais, então acorde um estilo.
  • Antes do 2019-09 a palavra-chave era definitions. Ambas podem ser alvo de $ref, mas o modo estrito pode relatar definitions como palavra-chave desconhecida.
  • $defs pode conter qualquer schema, incluindo um fragmento allOf ou um sub-schema independente com seu próprio $id.
  • Não trate $defs como herança de template. Ele só armazena definições; o significado ainda vem do contexto que referencia.

4. allOf, anyOf e oneOf não são intercambiáveis

Os três envolvem múltiplos ramos, mas suas regras de aceitação diferem. Quando os dados correspondem a dois ramos, allOf e anyOf passam enquanto oneOf falha — o motivo mais comum de as pessoas concluírem "o schema está quebrado".

Palavra-chave Correspondências mínimas Correspondências máximas Dados correspondendo a dois ramos Qualidade dos erros Uso típico
allOf Todas Ilimitadas Passa Boa, uma restrição por vez Combinar fragmentos de restrição
anyOf 1 Ilimitadas Passa Ruim, ramos se acumulam "E-mail ou telefone, qualquer um"
oneOf 1 1 Falha Ruim Unions discriminadas
not 0 0 Falha A pior Excluir alguns valores; raramente usado
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf não mescla definições de objeto; ele só exige que todo ramo valha. Cada ramo é avaliado independentemente, então um additionalProperties: false dentro de um ramo conhece apenas as chaves que aquele ramo declarou e sinalizará campos declarados por outro ramo como extras. No 2020-12 a correção é colocar a política de propriedade desconhecida no nível superior e usar unevaluatedProperties: false, que é avaliado uma vez após todos os ramos.

Use anyOf para "várias formas legais", não para "várias formas mutuamente exclusivas". Se dois ramos podem corresponder ao mesmo tempo, oneOf produz o famoso e inútil must match exactly one schema in oneOf.

5. if/then/else e discriminador: torne as condições decidíveis

Restrições condicionais tendem a produzir erros melhores que oneOf porque o caminho de decisão é explícito: if passa, then se aplica; caso contrário else se aplica.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

Três detalhes determinam se ele se comporta como pretendido:

  1. Uma entrada em properties dentro de if não falha quando a chave está ausente. Combine-a com required para expressar "kind existe e é igual a image".
  2. if em si nunca produz erro de validação. Falhá-lo simplesmente encaminha para else, então ele não consegue bloquear dados ruins sozinho.
  3. Um then ou else isolado é inerte. Eles só se aplicam ao lado de um if.

Conforme os ramos de union crescem, a cadeia condicional vira um aninhamento impossível de manter. Um padrão mais robusto é travar um campo de tag dentro de cada ramo, para que os ramos sejam mutuamente exclusivos por construção:

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator é uma extensão do OpenAPI 3.x, não uma palavra-chave do JSON Schema. O Ajv a suporta quando a opção discriminator: true é habilitada, mas seu mapeamento vive em mapping e não é portável entre cadeias de ferramentas. Se o schema precisa funcionar em vários validadores, prefira o padrão de tag const, que não depende de extensão nenhuma.

6. Referências recursivas: árvores, listas ligadas e arquivos que se citam

Estruturas recursivas não precisam de sintaxe especial — um $ref de volta a si mesmo basta. Validadores não expandem referências infinitamente em tempo de compilação; eles recorrem de acordo com a profundidade real dos dados, então algumas dezenas de linhas descrevem uma árvore de qualquer altura.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

Três fronteiras vale resolver de antemão:

  • Profundidade dos dados: o schema não impõe limite, mas validação e serialização consomem pilha proporcional à profundidade. APIs públicas devem acrescentar um limite de negócio, digamos oito níveis, ou um payload maliciosamente profundo pode estourar a pilha.
  • Referências mútuas: A referencia B e B referencia A é legal, mas ambos os schemas precisam ser registrados primeiro; caso contrário a ordem de resolução decide o desfecho.
  • Ramos anuláveis: se um campo recursivo pode ser null, escreva "type": ["object", "null"] ou envolva-o em oneOf. $ref não herda anulabilidade.

7. Referências entre arquivos e empacotamento: nunca busque schemas em produção

Referências entre arquivos frequentemente "simplesmente funcionam" em desenvolvimento porque o validador busca o alvo por $id pela rede. Mude a política de rede na hora do deploy e toda a cadeia de validação quebra. A regra de produção é simples: todo schema referenciado precisa estar registrado no processo ou empacotado antecipadamente.

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// Registre primeiro o arquivo referenciado; a chave precisa ser igual ao seu $id, ou refs relativas não resolvem
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// Compile o schema de entrada uma vez, no escopo do módulo
export const validateOrder = ajv.compile(orderSchema);

Se order.json tem $id: "https://bbdrug.com/schemas/order.json", seu "$ref": "address.json" resolve para https://bbdrug.com/schemas/address.json, que corresponde à chave registrada. Um único caractere diferente resulta em can't resolve reference.

As três abordagens práticas se comparam assim:

Abordagem Como Ponto forte Custo
Registrar em tempo de execução addSchema por arquivo Sem etapa de build, os arquivos permanecem independentes Ordem de registro e chaves são manuais
Empacotar em tempo de build Mesclar com um ref parser em um arquivo Zero dependências de tempo de execução Artefato maior, diffs barulhentos
Carregamento assíncrono loadSchema + compileAsync Lida com schemas que mudam com frequência Preocupações de rede e cache; evite em caminhos críticos

Para projetos de frontend, empacotar em tempo de build é mais seguro. No servidor, o registro basta, desde que um teste prove que toda referência resolve. De qualquer forma, rode compile no CI para que erros de referência apareçam antes do merge em vez de na primeira requisição de produção.

8. Ordem de depuração: resolver, compilar, depois inspecionar ramos

Quase todo problema de $ref pode ser localizado em três etapas, e a ordem importa:

Sintoma Causa provável Correção
can't resolve reference ... from id ... Erro de digitação em $id, base relativa errada, alvo não registrado Imprima o $id e a URI absoluta resolvida e depois compare com as chaves registradas
schema with key or id already exists O mesmo $id registrado duas vezes, ou compile chamado repetidamente Mova o registro para a inicialização do módulo e rode uma vez
strict mode: unknown keyword: "definitions" Mistura de sintaxe draft-07 Renomeie para $defs; não desabilite o modo estrito
Os dados correspondem a dois ramos oneOf Restrições de ramo sobrepostas, sem campo de tag Acrescente const a cada ramo, ou mude para anyOf
Restrições dentro de $defs não têm efeito Nada os referencia Verifique o caminho da referência; $defs não valida nada por si só

Ao depurar, leia schemaPath antes de message: ele nomeia o ramo e a palavra-chave que falharam e é muito mais confiável que a prosa em inglês. Faça cache do validador compilado no escopo do módulo em vez de recompilar por requisição. Para toda mudança de schema, cubra tanto um caso que passa quanto um que falha — testar apenas o caminho feliz não testa nada. Para seleção de validador e design de mensagens de erro, veja JSON Schema: do básico à produção; para conectar um schema à geração de tipos, veja Sincronização bidirecional entre Schema e tipos TypeScript.

9. Perguntas frequentes

$ref pode ficar ao lado de type ou description?

O 2020-12 permite $ref ao lado de palavras-chave irmãs, e essas irmãs são avaliadas normalmente. No draft-07 as irmãs são ignoradas e apenas $ref se aplica. Essa é a maior diferença comportamental quando o mesmo schema é usado entre drafts, então confirme qual draft seu validador suporta antes de migrar.

Devo usar $defs ou definitions?

Use $defs para trabalho novo; ele chegou com o 2019-09. definitions não é uma palavra-chave no 2020-12. Um pointer como $ref: "#/definitions/foo" ainda pode resolver porque é JSON Pointer puro, mas o modo estrito relatará uma palavra-chave desconhecida. Misturar os dois estilos deixa uma mina que só explode no CI.

Por que oneOf rejeita dados que parecem válidos?

Porque os dados satisfazem dois ramos ao mesmo tempo. oneOf exige exatamente um, e um ramo que restringe apenas parte do objeto é satisfeito mesmo quando os campos do outro ramo estão ausentes. Acrescente uma tag const a cada ramo para torná-los mutuamente exclusivos.

Referências recursivas farão loop para sempre?

Não. Validadores não expandem referências em tempo de compilação; eles recorrem sobre os dados reais. Os riscos reais são dados profundos o bastante para estourar a pilha e ordem de resolução errada entre arquivos mutuamente referenciados. Um limite de profundidade em nível de negócio em campos recursivos é mais eficaz que qualquer truque de schema.

A produção pode depender de $ref remoto?

Não. Referências remotas amarram a disponibilidade da validação à rede: mudanças de domínio, certificados expirados e isolamento de rede interna todos quebram a cadeia. Empacote os schemas referenciados no artefato ou registre-os na inicialização se quiser comportamento previsível.

Como decido entre $defs e arquivos separados?

Uma estrutura que aparece em dois lugares pode ser copiada uma vez; quando aparece em um terceiro lugar, promova-a para $defs. Só quando outra linguagem ou outro repositório precisa lê-la ela merece um arquivo autônomo com seu próprio $id.

Por que restrições dentro de $defs não têm efeito?

$defs é apenas um contêiner, e nós para os quais nenhum $ref aponta nunca participam da validação. Uma lista required no topo de $defs sem referência recebida ainda deixa os dados passarem — isso é a especificação, não um bug do validador.

Devo colocar additionalProperties: false dentro de ramos allOf?

Não. Cada ramo é avaliado independentemente, então um ramo trata campos declarados por outro ramo como extras. A abordagem do 2020-12 move a política de campo desconhecido para o nível superior e usa unevaluatedProperties: false, avaliado uma vez após todos os ramos.