Mengapa JSON Schema yang Dibuat Otomatis Terus Menolak Data Nyata
Intinya: Schema hasil generator sering memblokir data valid. Pelajari mengapa sampel yang lebih kaya menghasilkan schema yang lebih buruk, lalu alur kerja: pengambilan sampel multi-record, pelengkapan semantik, pelokasian error.

Intinya: tetapkan dulu batas required/opsional secara manual — berikan 5–20 record dengan bentuk beragam dan "semua wajib" dimatikan — lalu buat schema dan tambahkan format, enum, serta additionalProperties secara manual; sampel tunggal dengan semua field akan selalu terlalu ketat.
Generator hanya dapat melihat satu record yang Anda tempelkan. Ia membaca "record ini punya email" sebagai "setiap record harus punya email", jadi semakin lengkap sampel Anda, semakin banyak tuntutannya.
1. Generator melakukan pemetaan struktural, bukan pemahaman bisnis
Berikan sekumpulan JSON kepada generator dan ia melakukan tepat satu hal: menelusuri struktur nilai dan menerjemahkan setiap node menjadi kata kunci schema. Seluruh tabel pemetaannya tetap:
| Nilai dalam sampel | Tipe yang disimpulkan | Catatan |
|---|---|---|
"hello" |
{"type": "string"} |
Tidak membedakan email, tanggal, UUID |
42 |
{"type": "number"} |
Integer tidak disimpulkan sebagai integer |
3.14 |
{"type": "number"} |
Sama seperti di atas |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
Hanya mengenali null, tidak pernah "nullable" |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
Tipe elemen digabungkan |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required adalah inti masalahnya |
Semua masalah ada di baris terakhir. required tidak diturunkan dari tipe; ia diturunkan dari "key ini ada dalam sampel ini". Ketika sampel berupa satu record, pernyataan itu setara dengan "setiap key wajib".
Dan data yang dikembalikan API nyata hampir selalu memiliki setengah field opsional. Di situlah konflik dimulai.
2. Lima record serupa, lima tingkat penerimaan
Mulailah dengan pengukuran. Siapa pun yang melihat lima record di bawah akan mengatakan "bentuknya sama":
[
{
"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 muncul di 3 dari 5 record, admin hanya muncul sekali, dan record 2 memiliki array tags kosong.
Sekarang buat schema dari setiap record secara individual, lalu gunakan setiap hasil untuk memvalidasi kelima record (validator: ajv 8):
| Dibuat dari | Jumlah key | required yang dihasilkan |
Menerima dari 5 |
|---|---|---|---|
| Record 1 | 5 | id, name, email, tags, score |
3 record |
| Record 2 | 4 | id, name, tags, score |
5 record |
| Record 3 | 5 | id, name, email, tags, score |
3 record |
| Record 4 | 5 | id, name, email, tags, score |
1 record |
| Record 5 | 6 | id, name, email, tags, score, admin |
1 record |
Tidak ada dari kelima schema itu yang benar, tetapi satu-satunya yang menerima semua data berasal dari record 2 — record yang tidak memiliki email. Ia menang karena alasan sederhana: key yang tidak pernah muncul tidak pernah ditulis ke required.
Record 4 bahkan lebih instruktif: nilai email-nya null, jadi generator mengunci {"type": "null"} secara hardcode, dan empat record lain dengan email string semuanya ditolak.
3. Mengapa "tempel record terlengkap" adalah pilihan terburuk
Intuisinya adalah "tempel sampel dengan field terbanyak dan schema akan lengkap". Pengukuran menunjukkan sebaliknya — dan pihak ketiga menjalankan uji terkontrol yang lebih besar.
absurdtools menggunakan 3000 "keluarga data" acak, 8 record per keluarga, dengan setiap field opsional hadir kira-kira setengah waktu, dan mengukur berapa banyak data yang dapat diterima schema yang dihasilkan dari sampel dengan jumlah key berbeda:
| Jumlah key sampel | Bagian data keluarga yang diterima |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
Kesimpulannya bukan "penurunan ringan" melainkan pembalikan total: sampel minimal menghasilkan schema paling longgar di 95% keluarga, sementara sampel terlengkap hanya berhasil 0,1% dari waktu.
Alasannya terletak pada semantik required, yang berarti "semua"; keopsionalan hanya dapat diamati melalui ketidakhadiran. Menempelkan record dengan setiap field memberi tahu generator "tidak satu pun dari key ini boleh dihilangkan"; menempelkan record dengan field yang hilang menyerahkan bukti bahwa "key ini boleh tidak ada".
Ingat dalam satu kalimat: nilai sebuah sampel bukanlah kelengkapannya, melainkan ketidakhadiran mana yang dicakupnya.
Berapa banyak record yang cukup
Eksperimen yang sama juga mengukur kecepatan konvergensi: mengakumulasi record satu per satu dengan aturan "key tetap wajib hanya jika setiap record sejauh ini memilikinya", median jumlah record agar required konvergen ke himpunan wajib sebenarnya adalah 3; 6 record mencakup sekitar sembilan puluh persen keluarga, dan 12 mencakup semuanya.
Jadi mengambil sampel 5–10 record sehari-hari adalah nilai terbaik untuk usaha; memaksakan dua puluh atau lebih menghasilkan sangat sedikit tambahan.
Tetapi ada prasyarat: record ini harus berasal dari situasi yang benar-benar berbeda. Lima record yang disalin dari template yang sama berhenti memberikan informasi baru dari yang kedua — himpunan field mereka identik, dan required tidak dapat menyempit satu key pun.
4. Perbaikannya: berikan beberapa record sekaligus dan biarkan ketidakhadiran berbicara
Karena pengambilan sampel satu record pasti terlalu ketat, pendekatan yang tepat adalah membiarkan generator melihat beberapa record sekaligus dan mengonvergensi required dengan aturan "wajib hanya jika setiap record memilikinya".
Generator di situs ini menerima array secara langsung dan memiliki tombol "semua wajib". Matikan dan ia memutuskan berdasarkan jumlah kemunculan: hanya key yang dibawa oleh setiap record yang ditulis ke required.
Masih lima record yang sama — kali ini gabungkan menjadi array dan simpulkan bersama, lalu ambil items sebagai kontrak elemen:
| Record yang diberikan | required yang disimpulkan |
Validasi kelima record |
|---|---|---|
| 1 record | id, name, email, tags, score |
3 lolos |
| 2 record | id, name, tags, score |
4 lolos |
| 3 record | id, name, tags, score |
4 lolos |
| 4 record | id, name, tags, score |
5 lolos |
| 5 record | id, name, tags, score |
5 lolos |
required konvergen segera setelah record keempat ditambahkan, dan tidak berubah sesudahnya. email dan admin dengan benar ditempatkan di properties tetapi tidak di required. Dan karena email record 4 adalah null, inferensi multi-record menggabungkannya menjadi {"type": ["string", "null"]}, sehingga string maupun null lolos — sesuatu yang tidak pernah dapat dilakukan pengambilan sampel satu record.
Ini berada pada orde besaran yang sama dengan temuan absurdtools bahwa "required konvergen setelah median 3 record".
Namun inferensi multi-record memiliki titik buta sendiri: jika email dalam record ini kebetulan null di semuanya (katakanlah semua berasal dari akun tanpa profil terisi), hasil inferensi menjadi {"type": "null"}, dan email string tetap ditolak. Demikian pula, jika setiap nilai tags adalah array kosong, tipe elemen tidak dapat disimpulkan sama sekali.
Jadi pengambilan sampel memiliki satu persyaratan keras lagi: setiap bentuk dari setiap field harus muncul setidaknya sekali. Bawa satu string, satu angka, satu null, dan satu array kosong; jangan biarkan bentuk apa pun sepenuhnya absen.
Secara konkret:
- Ambil 5–20 respons dari log, mock, atau request nyata, dan pastikan menyertakan baris abnormal (yang field-nya kosong, yang berisi null, yang array-nya kosong);
- Susun mereka menjadi array JSON dan tempelkan ke generator;
- Matikan "semua wajib" dan pilih draft 2020-12;
- Hasilnya adalah
type: arrayplus sebuahitems; ambil bagianitemssebagai kontrak untuk satu record; - Segera validasi sampel asli dengan schema ini — 100% harus lolos. Jika bahkan satu gagal, pengambilan sampel Anda tidak cukup; kembali dan tambahkan record.
Langkah 5 adalah pemeriksaan mandiri termurah. Jika generator bahkan tidak dapat mengakomodasi data yang Anda berikan, metode pengambilan sampel Anda salah, dan tidak ada gunanya melanjutkan.
5. Empat hal yang tidak dapat diisi generator — Anda harus menulisnya manual
Bahkan setelah memberikan beberapa record, berikut ini tetap tidak dapat disimpulkan dan harus ditambahkan secara manual:
| Bagian yang hilang | Perilaku terukur | Konsekuensi | Cara memperbaiki |
|---|---|---|---|
| Format string | "a@b.com" hanya menghasilkan {"type": "string"} |
Memvalidasi "not an email" lolos |
Tambahkan "format": "email" dan aktifkan pernyataan format |
| Rentang dan enum | Tidak dapat melihat bahwa score terbatas pada 0–100, maupun bahwa status hanya memiliki tiga nilai |
Angka negatif, nilai besar, dan nilai enum sampah semuanya lolos | Tambahkan minimum / maximum / enum |
| Kebijakan field tak dikenal | additionalProperties tidak dihasilkan |
Key yang salah ketik diterima diam-diam, yang lebih berbahaya daripada error | Tambahkan "additionalProperties": false untuk file bergaya konfigurasi |
| Semantik nullability dan koleksi kosong | Sampel tunggal yang berisi null terkunci ke {"type": "null"}; array kosong menghasilkan "items": {} |
Nilai valid ditolak, atau elemen array sepenuhnya tidak dibatasi | Ubah menjadi ["string", "null"], dan beri array kosong elemen nyata |
Baris ketiga layak disebut terpisah: nilai default additionalProperties adalah "izinkan". Artinya file konfigurasi yang menulis timeout sebagai timeuot lolos validasi lalu diam-diam tidak melakukan apa pun saat runtime. Untuk schema bergaya konfigurasi, ini adalah item yang harus Anda matikan secara eksplisit.
format juga punya jebakan: sejak draft 2019-09, format termasuk kosakata anotasi, dan secara default hanya menganotasi bukan menegakkan. Jika Anda menambahkan format: "email" dan nilai ilegal masih lolos, dalam delapan dari sepuluh kasus validator belum mengaktifkan validateFormats atau plugin format yang sesuai — bukan karena schema-nya salah.
Catatan tentang pilihan draft sementara kita di sini: jika schema akan masuk ke OpenAPI 3.1, ia harus draft 2020-12, karena 3.1 menyematkan versi itu secara langsung; toolchain lama (ekosistem Swagger 2.0 dan beberapa implementasi $ref) masih berpusat pada draft-07. Keduanya berperilaku berbeda untuk kata kunci saudara $ref dan untuk kata kunci terkait array, jadi jangan mencampurnya dalam satu set schema.
6. Untuk API pihak ketiga tanpa dokumentasi, rekayasa balik kontrak dari sampel respons
Mengintegrasikan callback pihak ketiga, bekerja dengan mitra yang tidak menulis dokumentasi, atau memelihara sistem warisan berusia sepuluh tahun — semuanya berarti "datanya ada di tangan, spesifikasinya tidak". Di sini generator bukan sekadar kemudahan; ia adalah jalur tercepat menuju kontrak.
Metodenya sama seperti di atas, dengan dua keharusan tambahan:
Satu: ambil sampel lintas waktu dan lintas keadaan. Field pihak ketiga sering berubah dengan keadaan bisnis — promosi menambahkan discount, pesanan refund menambahkan refundAt, dan respons error tidak memiliki data sama sekali. Mengambil satu snapshot berarti hanya melihat satu keadaan.
Berikan beberapa keberhasilan, beberapa kegagalan, dan beberapa hasil kosong bersama-sama: error dan data sering muncul secara saling eksklusif, dan inferensi multi-record menempatkan keduanya di properties sambil mengeluarkan mereka dari required — persis sesuai kenyataan.
Dua: simpan schema di repositori dan lampirkan pemeriksaan harian. Validasi respons nyata hari ini terhadapnya, dan Anda akan mengetahui field yang diam-diam menghilang atau berubah tipe sebelum pengguna Anda mengetahuinya.
// Jalankan sekali sehari: peringatan drift kontrak
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,
);
}
Nilai praktik ini bukan "menghemat usaha menulisnya manual", melainkan mengubah kesepakatan verbal menjadi peringatan yang dapat dieksekusi. Pihak ketiga tidak pernah memberi tahu Anda sebelumnya bahwa mereka mengubah field, tetapi perubahan itu pasti akan muncul di log Anda.
Satu batas yang harus ditarik jelas: schema hasil rekayasa balik mendeskripsikan apa yang dikembalikan pihak lain sekarang, bukan apa yang mereka janjikan untuk dikembalikan. Yang pertama untuk pemantauan; hanya yang terakhir yang layak masuk kontrak atau dokumentasi API.
7. Setelah selesai, buat error validasi menunjuk kembali ke field spesifik
Setelah schema dapat digunakan, kekhawatiran berikutnya adalah error yang dapat dibaca. Objek error ajv memiliki lima field, dan empat pertama adalah yang berguna:
| Field | Arti | Contoh (id: 0 melanggar minimum: 1) |
|---|---|---|
instancePath |
JSON Pointer ke nilai bermasalah dalam data | "/id" |
schemaPath |
Lokasi aturan pemicu di dalam schema | "#/properties/id/minimum" |
keyword |
Nama batasan yang gagal | "minimum" |
params |
Detail batasan tersebut yang dapat dibaca mesin | { "comparison": ">=", "limit": 1 } |
message |
Prosa bahasa Inggris yang berubah antar versi, jangan uraikan | "must be >= 1" |
Satu asimetri paling mudah tersandung: ketika required gagal, instancePath menunjuk objek induk (biasanya string kosong, yaitu akar), bukan field yang hilang. Nama key yang hilang ada di params.missingProperty. Demikian pula, ketika additionalProperties gagal, nama key tambahan ada di params.additionalProperty.
Jadi saat memetakan error ke "field → pesan", tangani dua kasus khusus ini terlebih dahulu:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
/** Mengubah array error ajv menjadi { fieldPath: [message] } untuk penyorotan form */
function groupByField(errors) {
const grouped = {};
for (const error of errors) {
// For required and additionalProperties, the offending field lives in params, not in 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 tidak opsional. Di bawah konfigurasi default ajv berhenti pada error pertama, sehingga pengguna memperbaiki satu field, mengirim ulang, mendapatkan error berikutnya, dan membutuhkan tiga perjalanan pulang-pergi hanya untuk mengisi satu form. Jika Anda juga menggunakan ajv-errors untuk pesan kustom, tanpa flag ini plugin akan diam-diam mengganti hanya sebagian pesan.
8. Proses peluncuran yang dapat digunakan kembali
Rangkai langkah-langkah di atas menjadi rutinitas tetap dan ikuti setiap kali Anda mengintegrasikan API baru:
- Ambil sampel: ambil 5–20 respons nyata, sengaja memilih yang memiliki field kosong, null, array kosong, dan field tambahan;
- Simpulkan: tempelkan bersama sebagai array ke generator, matikan "semua wajib", dan ambil
items; - Lengkapi semantiknya: tambahkan
format,enum,minimum/maximum,minItems, danadditionalPropertiessatu item demi satu; - Periksa mandiri: sampel asli harus lolos 100%, jika tidak kembali ke langkah 1 dan tambahkan sampel;
- Bukti negatif: siapkan masukan "seharusnya gagal" untuk setiap batasan dan pastikan ia benar-benar gagal — menguji hanya jalur yang lolos sama dengan tidak menguji;
- Commit: letakkan schema di kontrol versi, pertahankan sampel yang gagal sebagai kasus uji, dan jalankan pengujian terlebih dahulu setiap kali API mengubah field.
Pengujian negatif pada langkah 5 paling mudah dilewati. Menambahkan key ke required tanpa pernah memverifikasi bahwa key yang hilang menghasilkan error berarti bertaruh bahwa validator setuju dengan model mental Anda.
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);
// Juga nyatakan alasannya, agar pengujian tidak lolos karena field lain gagal
expect(
validate.errors?.some((e) => e.params?.missingProperty === "name"),
).toBe(true);
});
});
Pernyataan terakhir itu penting. Menyatakan hanya false dapat tetap hijau bahkan setelah schema dirusak — kegagalannya mungkin berasal dari penyebab yang sama sekali berbeda saat itu.
9. FAQ
Dapatkah schema yang dihasilkan generator langsung ke produksi
Tidak. Ia hanyalah snapshot struktur. Anda harus minimal menambahkan format, rentang, enum, dan kebijakan field tak dikenal, lalu menjalankan sampel asli maupun sampel negatif melaluinya. Generator menyelesaikan "mengetik properties manual itu membosankan", bukan "ia memutuskan aturan bisnis".
Untuk sistem kata kunci penulisan schema, pemisahan $ref, dan kata kunci komposisi, lihat JSON Schema: Dari Dasar hingga Produksi.
Mengapa record dengan field terbanyak menolak lebih banyak data
Karena required dikumpulkan dari "apa yang muncul dalam sampel". Sampel dengan semua field mengubah setiap key opsional menjadi wajib, sementara sebagian besar key itu tidak ada dalam data nyata. Memberikan beberapa record, atau record yang sengaja menghilangkan field, membawa Anda lebih dekat ke kebenaran daripada sampel terlengkap.
Apakah integer akan disimpulkan sebagai integer
Generator di situs ini tidak melakukan itu: baik integer maupun float dikeluarkan sebagai number, sehingga schema yang dihasilkan dari {"price": 5} menerima 5.99. Itu menghindari kecelakaan seperti "sampel kebetulan berupa integer, jadi produksi menolak harga dengan desimal". Beberapa alat pihak ketiga memang menyimpulkan integer dari nilainya, jadi berhati-hatilah dengan field uang.
Untuk detail tentang presisi angka dan batas tipe, lihat Enam tipe data JSON dan jebakan presisi angka.
Saya menambahkan format tetapi tidak berpengaruh
Sejak draft 2019-09, format hanya anotasi secara default. Entah aktifkan pernyataan format secara eksplisit di validator (ajv membutuhkan ajv-formats), atau gunakan pattern untuk batasan keras. Jangan mengasumsikan menambahkannya akan memblokir apa pun.
required melaporkan error, tetapi field jelas ada dalam data
Lihat instancePath terlebih dahulu. Error required menunjuk objek induk, yang berarti key yang hilang tidak berada pada level itu — penyebab umum adalah level penyarangan yang salah (/profile/name tidak sama dengan name di akar), perbedaan huruf (userId versus userID), atau serialisasi hulu yang membuang undefined sepenuhnya.
Sebelum menyelidiki, pastikan dokumen itu sendiri adalah JSON valid: komentar, trailing comma, dan masalah serupa gagal pada tahap parsing, sebelum schema mendapat kesempatan. JSON Parse & Format menunjuk langsung baris yang bermasalah.
Berapa banyak record yang harus saya ambil sampelnya, dan draft mana yang harus digunakan?
Mengambil sampel 5–10 record sehari-hari memberikan nilai terbaik; required konvergen pada median 3 record, dan 12 record mencakup setiap keluarga. Setiap record harus berasal dari situasi yang benar-benar berbeda, dan setiap bentuk dari setiap field harus muncul setidaknya sekali. Kunci draft ke 2020-12, karena OpenAPI 3.1 hanya menerima versi itu.
Mengapa default additionalProperties yang permisif lebih berbahaya?
Default tidak memancarkan kata kunci sama sekali, sehingga key yang salah ketik diterima diam-diam, lolos validasi, lalu gagal saat runtime — lebih sulit didiagnosis daripada error langsung. Schema bergaya konfigurasi harus mengatur "additionalProperties": false secara eksplisit, sementara schema API harus lebih dulu menimbang biaya kompatibilitas.
Haruskah saya menggunakan schema hasil rekayasa balik sebagai gerbang keras?
Tidak. Schema hasil rekayasa balik mendeskripsikan apa yang dikembalikan pihak lain sekarang, bukan apa yang mereka janjikan untuk dikembalikan, jadi ia layak untuk pemantauan dan peringatan drift. Apa pun yang masuk kontrak atau dokumentasi API harus merupakan kontrak yang dijanjikan.