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.

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
$defstidak membawa semantik.address,Address, danaddrsemuanya legal, jadi sepakati satu gaya. - Sebelum 2019-09 kata kuncinya adalah
definitions. Keduanya dapat ditargetkan oleh$ref, tetapi mode ketat dapat melaporkandefinitionssebagai kata kunci tak dikenal. $defsdapat menampung schema apa pun, termasuk fragmenallOfatau subschema mandiri dengan$idsendiri.- Jangan memperlakukan
$defssebagai 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:
- Entri
propertiesdi dalamiftidak gagal ketika key hilang. Pasangkan denganrequireduntuk mengekspresikan "kind ada dan sama dengan image". ifsendiri tidak pernah menghasilkan error validasi. Gagal di dalamnya hanya mengarahkan keelse, sehingga ia tidak dapat memblokir data buruk dengan sendirinya.thenatauelseyang berdiri sendiri bersifat inert. Mereka hanya berlaku di samping sebuahif.
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 dalamoneOf.$reftidak 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.