JSON Toolkit

Menggunakan Ulang JSON Schema: $ref, $defs, dan Komposisi allOf/oneOf

Intinya: $ref menyelesaikan penggunaan ulang, allOf dan oneOf menyelesaikan komposisi, dan menumpuk keduanya adalah tempat hal-hal rusak. Mencakup $id, $defs, kondisional, rekursi, bundling, dan debugging.

Menengah±11 menit bacaDiterbitkan 2026-10-31Diperbarui 2026-10-31
Diagram penggunaan ulang dan komposisi JSON Schema dengan $ref, $defs, dan allOf/oneOf

Intinya: ekstrak dulu struktur bersama ke dalam $defs dengan $ref, lalu gunakan allOf/oneOf; ketika branch dapat cocok bersamaan, gunakan anyOf atau tag const, dan sisakan oneOf untuk kasus yang benar-benar membutuhkan tepat satu kecocokan.

Menyalin-tempel menghemat sepuluh menit dan menghabiskan biaya sebuah percabangan senyap tiga bulan kemudian, ketika tidak ada yang ingat salinan mana yang benar.

1. Tiga lapisan penggunaan ulang — pilih satu sebelum memisahkan file

Naluri pertama, begitu field mulai berlipat, adalah memasukkan setiap batasan ke dalam satu file. Itu adalah cara tercepat untuk mengirim, tetapi setelah beberapa ratus baris setiap perubahan field harus dicerminkan di tiga tempat. Lewati satu dan Anda mendapatkan "lolos secara lokal, mengembalikan 500 di produksi". Penggunaan ulang berarti menulis satu fakta sekali dan merujuknya di mana-mana.

JSON Schema menawarkan tiga lapisan, masing-masing dengan cakupan berbeda:

Lapisan Pembawa Cakupan Penggunaan khas
Sub-struktur $defs + $ref Satu file schema Alamat, uang, paginasi, bentuk error
File Schema mandiri + $id Beberapa file dalam satu layanan Entitas user, order, product
Pustaka kontrak Repositori schema + artefak terbundel Beberapa layanan, beberapa bahasa Komponen OpenAPI, kontrak terpublikasi

Untuk sebagian besar proyek lapisan pertama sudah cukup. Memisahkan menjadi file hanya bermanfaat ketika dua layanan benar-benar berbagi kontrak yang sama, atau ketika schema adalah artefak terpublikasi yang dikonsumsi bahasa lain. Memisahkan terlalu awal membuat resolusi $id dan path relatif lebih sulit di-debug, dan biaya itu biasanya melebihi penghematan dari menghindari duplikasi.

Aturan praktis sederhana: jika sebuah struktur muncul di dua tempat, satu salinan dapat ditoleransi; ketika muncul di tempat ketiga, promosikan ke $defs; hanya ketika bahasa atau repositori lain harus membacanya ia layak mendapatkan file mandiri dengan $id sendiri.

2. $id menetapkan identitas, $ref melakukan lompatan

$id bukan komentar. Ia mendeklarasikan base URI schema. Nilai $ref adalah referensi URI: ia diselesaikan terhadap base URI saat ini menjadi URI absolut, lalu fragmen setelah # melokasikan node. Jika base URI salah, setiap referensi relatif menunjuk ke tempat lain, tetapi error biasanya hanya mengatakan "can't resolve reference" tanpa menyebut host yang salah.

{
  "$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 menggunakan JSON Pointer sementara billTo menggunakan anchor; keduanya mendarat pada node yang sama. Tabel di bawah memetakan setiap bentuk ke resolusinya:

Bentuk Menunjuk ke Draft Catatan
# Akar schema saat ini Semua Umum untuk rekursi
#/$defs/address Node di bawah $defs 2019-09 dan setelahnya Paling jelas; disukai
#address Anchor bernama $anchor pada 2020-12 Draft-07 menulis $id: "#address"
common.json File relatif terhadap base URI Semua Harus didaftarkan atau dibundel
https://example.com/x.json URI absolut Semua Hindari pengambilan saat runtime di produksi

Satu jebakan halus: jika subschema di dalam $defs mendeklarasikan $id sendiri, itu menjadi base URI baru untuk semua yang di bawahnya. Tempel schema yang disalin dari file lain dan $id yang tersisa akan mengalihkan referensi relatifnya ke host asli — dan validator hanya menyadarinya ketika benar-benar mencoba menyelesaikannya.

3. $defs adalah wadah, bukan lokasi validasi

$defs adalah map biasa. Tidak ada di dalamnya yang berlaku sampai sebuah $ref menunjuk ke sana. Daftar required yang duduk di atas $defs tanpa referensi masuk tidak memvalidasi apa pun, seketat apa pun tampilannya. Itu adalah spesifikasi yang bekerja sebagaimana mestinya, bukan bug validator.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "A two-decimal money string that avoids floating-point drift"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

Beberapa konvensi layak ditetapkan dalam tim:

  • Key di dalam $defs tidak membawa semantik. address, Address, dan addr semuanya legal, jadi sepakati satu gaya.
  • Sebelum 2019-09 kata kuncinya adalah definitions. Keduanya dapat ditargetkan oleh $ref, tetapi mode ketat dapat melaporkan definitions sebagai kata kunci tak dikenal.
  • $defs dapat menampung schema apa pun, termasuk fragmen allOf atau subschema mandiri dengan $id sendiri.
  • Jangan memperlakukan $defs sebagai pewarisan template. Ia hanya menyimpan definisi; makna tetap datang dari konteks yang merujuk.

4. allOf, anyOf, dan oneOf tidak dapat dipertukarkan

Ketiganya melibatkan beberapa branch, tetapi aturan penerimaannya berbeda. Ketika data cocok dengan dua branch, allOf dan anyOf lolos sementara oneOf gagal — alasan tunggal paling umum orang menyimpulkan "schema-nya rusak".

Kata kunci Kecocokan minimum Kecocokan maksimum Data cocok dengan dua branch Kualitas error Penggunaan khas
allOf Semua Tak terbatas Lolos Baik, satu batasan dalam satu waktu Menggabungkan fragmen batasan
anyOf 1 Tak terbatas Lolos Buruk, branch menumpuk "Email atau telepon, salah satu"
oneOf 1 1 Gagal Buruk Discriminated union
not 0 0 Gagal Terburuk Mengecualikan beberapa nilai; jarang digunakan
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf tidak menggabungkan definisi objek; ia hanya mengharuskan setiap branch berlaku. Setiap branch dievaluasi secara independen, sehingga additionalProperties: false di dalam satu branch hanya mengetahui key yang dideklarasikan branch itu dan akan menandai field yang dideklarasikan branch lain sebagai tambahan. Pada 2020-12 perbaikannya adalah meletakkan kebijakan properti tak dikenal di level atas dan menggunakan unevaluatedProperties: false, yang dievaluasi sekali setelah semua branch.

Gunakan anyOf untuk "beberapa bentuk legal", bukan untuk "beberapa bentuk yang saling eksklusif". Jika dua branch dapat cocok bersamaan, oneOf menghasilkan must match exactly one schema in oneOf yang terkenal tidak membantu.

5. if/then/else dan discriminator: buat kondisi dapat diputuskan

Batasan kondisional cenderung menghasilkan error yang lebih baik daripada oneOf karena jalur keputusannya eksplisit: if lolos, then berlaku; jika tidak, else berlaku.

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

Tiga detail menentukan apakah ia berperilaku sebagaimana dimaksud:

  1. Entri properties di dalam if tidak gagal ketika key hilang. Pasangkan dengan required untuk mengekspresikan "kind ada dan sama dengan image".
  2. if sendiri tidak pernah menghasilkan error validasi. Gagal di dalamnya hanya mengarahkan ke else, sehingga ia tidak dapat memblokir data buruk dengan sendirinya.
  3. then atau else yang berdiri sendiri bersifat inert. Mereka hanya berlaku di samping sebuah if.

Saat branch union bertambah, rantai kondisional menjadi penyarangan yang tidak dapat dipelihara. Pola yang lebih kokoh adalah mengunci field tag di dalam setiap branch sehingga branch saling eksklusif secara konstruksi:

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

discriminator adalah ekstensi OpenAPI 3.x, bukan kata kunci JSON Schema. Ajv mendukungnya ketika opsi discriminator: true diaktifkan, tetapi pemetaannya berada di mapping dan tidak portabel antar toolchain. Jika schema harus bekerja di beberapa validator, utamakan pola tag const, yang tidak bergantung pada ekstensi apa pun.

6. Referensi rekursif: pohon, linked list, dan file yang saling merujuk

Struktur rekursif tidak memerlukan sintaks khusus — sebuah $ref kembali ke dirinya sendiri sudah cukup. Validator tidak memperluas referensi tanpa batas saat kompilasi; mereka melakukan rekursi sesuai kedalaman data aktual, sehingga beberapa lusin baris mendeskripsikan pohon dengan tinggi apa pun.

{
  "$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"
}

Tiga batas layak ditetapkan di muka:

  • Kedalaman data: schema tidak memberlakukan batas, tetapi validasi dan serialisasi keduanya mengonsumsi stack sebanding dengan kedalaman. API publik harus menambahkan batas bisnis, katakanlah delapan level, atau payload yang sangat dalam secara jahat dapat meluap stack.
  • Referensi saling: A merujuk B dan B merujuk A legal, tetapi kedua schema harus didaftarkan terlebih dahulu; jika tidak, urutan resolusi menentukan hasilnya.
  • Branch nullable: jika field rekursif dapat bernilai null, tulis "type": ["object", "null"] atau bungkus dalam oneOf. $ref tidak mewarisi nullability.

7. Referensi lintas file dan bundling: jangan pernah mengambil schema di produksi

Referensi lintas file sering "langsung bekerja" dalam pengembangan karena validator mengambil target berdasarkan $id melalui jaringan. Ubah kebijakan jaringan saat deploy dan seluruh rantai validasi rusak. Aturan produksinya sederhana: setiap schema yang dirujuk harus didaftarkan dalam proses atau dibundel terlebih dahulu.

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 });

// Daftarkan file yang dirujuk terlebih dahulu; kuncinya harus sama dengan $id-nya, atau ref relatif tidak akan diselesaikan
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// Kompilasi schema entri sekali, di scope modul
export const validateOrder = ajv.compile(orderSchema);

Jika order.json memiliki $id: "https://bbdrug.com/schemas/order.json", "$ref": "address.json"-nya diselesaikan menjadi https://bbdrug.com/schemas/address.json, yang cocok dengan kunci yang didaftarkan. Satu karakter yang berbeda menghasilkan can't resolve reference.

Ketiga pendekatan praktis membandingkan seperti ini:

Pendekatan Cara Kekuatan Biaya
Daftarkan saat runtime addSchema per file Tidak ada langkah build, file tetap independen Urutan pendaftaran dan kunci bersifat manual
Bundel saat build Gabungkan dengan parser ref menjadi satu file Tidak ada ketergantungan runtime Artefak lebih besar, diff berisik
Pemuatan asinkron loadSchema + compileAsync Menangani schema yang sering berubah Kekhawatiran jaringan dan cache; hindari di jalur kritis

Untuk proyek frontend, bundling saat build lebih aman. Di server, pendaftaran sudah cukup, asalkan pengujian membuktikan setiap referensi terselesaikan. Dengan cara apa pun, jalankan compile di CI agar error referensi muncul sebelum merge alih-alih pada request produksi pertama.

8. Urutan debugging: resolusi, kompilasi, lalu periksa branch

Hampir setiap masalah $ref dapat dilokasikan dalam tiga tahap, dan urutannya penting:

Gejala Penyebab yang mungkin Perbaikan
can't resolve reference ... from id ... Salah ketik di $id, base relatif salah, target tidak didaftarkan Cetak $id dan URI absolut yang diselesaikan, lalu bandingkan dengan kunci yang didaftarkan
schema with key or id already exists $id yang sama didaftarkan dua kali, atau compile dipanggil berulang Pindahkan pendaftaran ke inisialisasi modul dan jalankan sekali
strict mode: unknown keyword: "definitions" Mencampur sintaks draft-07 Ganti nama menjadi $defs; jangan menonaktifkan mode ketat
Data cocok dengan dua branch oneOf Batasan branch tumpang tindih, tidak ada field tag Tambahkan const ke setiap branch, atau beralih ke anyOf
Batasan di dalam $defs tidak berpengaruh Tidak ada yang merujuknya Periksa jalur referensi; $defs tidak memvalidasi apa pun dengan sendirinya

Saat debugging, baca schemaPath sebelum message: ia menyebutkan branch dan kata kunci yang gagal dan jauh lebih andal daripada prosa bahasa Inggris. Cache validator terkompilasi di scope modul alih-alih mengompilasi ulang per request. Untuk setiap perubahan schema, cakup kasus yang lolos dan yang gagal — menguji hanya jalur bahagia tidak menguji apa pun. Untuk pemilihan validator dan desain pesan error, lihat JSON Schema: Dari Dasar hingga Produksi; untuk menyambungkan schema ke pembuatan tipe, lihat Sinkronisasi Dua Arah antara Schema dan Tipe TypeScript.

9. Pertanyaan yang sering diajukan

Dapatkah $ref berada di samping type atau description?

2020-12 mengizinkan $ref bersama kata kunci saudara, dan saudara itu dievaluasi secara normal. Pada draft-07 saudara diabaikan dan hanya $ref yang berlaku. Ini adalah perbedaan perilaku terbesar ketika schema yang sama digunakan lintas draft, jadi pastikan dulu draft mana yang didukung validator Anda sebelum migrasi.

Haruskah saya menggunakan $defs atau definitions?

Gunakan $defs untuk pekerjaan baru; ia datang bersama 2019-09. definitions bukan kata kunci pada 2020-12. Pointer seperti $ref: "#/definitions/foo" masih dapat diselesaikan karena itu JSON Pointer biasa, tetapi mode ketat akan melaporkan kata kunci tak dikenal. Mencampur kedua gaya meninggalkan ranjau yang hanya meledak di CI.

Mengapa oneOf menolak data yang terlihat valid?

Karena data memenuhi dua branch sekaligus. oneOf menuntut tepat satu, dan branch yang hanya membatasi sebagian objek terpenuhi bahkan ketika field branch lain tidak ada. Tambahkan tag const ke setiap branch agar mereka saling eksklusif.

Apakah referensi rekursif akan berulang selamanya?

Tidak. Validator tidak memperluas referensi saat kompilasi; mereka melakukan rekursi atas data aktual. Risiko sebenarnya adalah data yang cukup dalam untuk meluap stack dan urutan resolusi yang salah antar file yang saling merujuk. Batas kedalaman tingkat bisnis pada field rekursif lebih efektif daripada trik schema apa pun.

Dapatkah produksi mengandalkan $ref remote?

Jangan. Referensi remote mengikat ketersediaan validasi pada jaringan: perubahan domain, sertifikat kedaluwarsa, dan isolasi jaringan internal semuanya memutus rantai. Bundel schema yang dirujuk ke dalam artefak atau daftarkan saat startup jika Anda menginginkan perilaku yang dapat diprediksi.

Bagaimana saya memutuskan antara $defs dan file terpisah?

Struktur yang muncul di dua tempat dapat disalin sekali; ketika muncul di tempat ketiga, promosikan ke $defs. Hanya ketika bahasa atau repositori lain harus membacanya ia layak mendapatkan file mandiri dengan $id sendiri.

Mengapa batasan di dalam $defs tidak berpengaruh?

$defs hanyalah wadah, dan node yang tidak dirujuk $ref tidak pernah berpartisipasi dalam validasi. Daftar required di atas $defs tanpa referensi masuk tetap membiarkan data lolos — itu adalah spesifikasi, bukan bug validator.

Haruskah saya meletakkan additionalProperties: false di dalam branch allOf?

Tidak. Setiap branch dievaluasi secara independen, sehingga satu branch memperlakukan field yang dideklarasikan branch lain sebagai tambahan. Pendekatan 2020-12 memindahkan kebijakan field tak dikenal ke level atas dan menggunakan unevaluatedProperties: false, dievaluasi sekali setelah semua branch.