Sinkronisasi Dua Arah antara Schema dan Tipe TypeScript
Intinya: Tipe dan schema masing-masing mencakup satu segmen: satu saat kompilasi, satu saat runtime. Pertanyaannya bukan mana yang dipilih, tetapi bagaimana menjaga keduanya tetap sinkron secara otomatis.

Intinya: pilih dulu satu sumber kebenaran — schema-first ketika backend memiliki kontraknya, types-first ala zod ketika frontend yang memilikinya — lalu hasilkan salinan lainnya dan bandingkan di CI; tulis manual hanya semantik yang tidak dapat diekspresikan generator, seperti branded type.
Jika Anda menulis kedua definisi secara manual, mereka akan menyimpang. Harga dari penyimpangan adalah "ia terkompilasi, lalu mengembalikan 500 saat runtime."
1. Mengapa menyimpan hanya satu salinan tidak berhasil
| Kemampuan | Tipe TypeScript | JSON Schema |
|---|---|---|
| Pemeriksaan saat kompilasi | ✓ | ✗ |
| Validasi runtime | ✗ (tipe dihapus) | ✓ |
| Penggunaan lintas bahasa | ✗ | ✓ (Java/Go/Python semuanya dapat membacanya) |
| Pelengkapan dan refactoring IDE | ✓ | Lumayan |
| Pembuatan dokumentasi | Lumayan | ✓ (ekosistem OpenAPI) |
Masukan ke API berasal dari jaringan dan selalu unknown, sehingga validasi runtime tidak dapat dilewati; dan saat menulis kode bisnis Anda tetap membutuhkan pelengkapan tipe.
Pendekatan yang masuk akal adalah memilih satu sebagai sumber kebenaran dan menghasilkan yang lain.
2. Jalur A: schema dulu
Cocok untuk: pekerjaan yang digerakkan backend, penggunaan lintas bahasa, dan kontrak publik.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* Menghasilkan definisi tipe TypeScript dari JSON Schema.
* @param schemaPath Path ke file schema
* @param outputPath Path ke file keluaran
* @returns Promise yang resolve ketika pembuatan selesai
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
Beberapa konvensi dalam keluaran layak diperhatikan:
required: ["id"]→id: string; key yang tidak tercantum dalamrequired→id?: string;"type": ["string", "null"]→string | null;enum→ union tipe literal, jauh lebih tepat daripadastring;additionalProperties: falsemenentukan apakah index signature dihasilkan.
Ada juga opsi tanpa build, json-schema-to-ts, yang menyimpulkan langsung di level tipe:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
Keunggulannya adalah tidak ada langkah pembuatan dan tipe yang selalu sinkron dengan schema; kelemahannya adalah schema harus ditulis sebagai as const, dan skenario $ref yang kompleks dikompilasi dengan lambat.
3. Jalur B: tipe dulu
Cocok untuk: pekerjaan yang digerakkan frontend dengan library seperti zod / valibot, di mana "schema adalah tipenya".
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** Kontrak yang dipublikasikan ke bahasa lain */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
Di sini zod memainkan kedua peran — "definisi tipe" dan "validasi runtime" — dan JSON Schema hanyalah artefak ekspor. Ini adalah usaha paling sedikit secara keseluruhan, tetapi ingat dua hal:
z.inferdan JSON Schema yang dihasilkan tidak memetakan secara tepat untukdefault,transform, danrefine;- Schema yang dihasilkan mungkin berisi
$refdandefinitions, jadi periksa sebelum menerbitkan apakah ia sesuai dengan spesifikasi target.
4. Arah sebaliknya juga perlu diperiksa
Jalur mana pun yang Anda pilih, tambahkan pemeriksaan CI: artefak yang dihasilkan harus cocok dengan file di repositori.
# Hasilkan ke direktori sementara dan bandingkan; gagalkan CI pada perbedaan apa pun
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
Tanpa langkah ini, tiga bulan kemudian tidak ada yang tahu apakah api.d.ts masih terkini.
5. Empat jebakan frekuensi tinggi
5.1 optional dan nullable bukan hal yang sama
type A = { name?: string }; // key boleh tidak ada
type B = { name: string | null }; // key harus ada, tetapi nilainya boleh null
Dalam istilah schema:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
Generator menangani kedua kasus ini sepenuhnya berbeda. Kontrak harus menyatakan mana yang Anda maksud; jika tidak, data.name?.length di front end akan crash pada null.
5.2 Enum yang berevolusi
type Status = 'pending' | 'paid' | 'refunded';
Jika backend menambahkan 'cancelled' dan tipe frontend tidak diperbarui, kode jatuh ke branch default.
Field mirip enum harus menangani nilai tak dikenal secara eksplisit:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Tanggal dan uang tidak bisa sekadar menggunakan string
Dalam schema, format: "date-time" hanyalah anotasi; dalam TypeScript ia hanya string.
Ketika Anda memerlukan jaminan lebih kuat, gunakan "branded type":
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* Menandai string sebagai timestamp ISO.
* @param value String yang formatnya sudah diverifikasi
* @returns String timestamp yang telah di-brand
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 Integer besar
type: "integer" tidak setara dengan number begitu nilainya melebihi 2^53. Tulis dalam kontrak sebagai:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. Proses peluncuran yang disarankan
- Definisikan kontrak: mulai dengan menulis struktur request/response sebagai schema (atau zod);
- Hasilkan tipe: hasilkan
.d.tsdi CI dan commit ke repositori agar perbedaan mudah ditinjau; - Hasilkan validator: kompilasi schema yang sama menjadi fungsi validasi Ajv, dan panggil di setiap titik masuk server;
- Hasilkan dokumentasi: keluarkan Markdown atau OpenAPI dari schema agar dokumentasi tidak pernah usang secara manual;
- Regresi: jalankan fixture API melalui "pemeriksaan tipe" dan "validasi schema" keduanya; hanya lolos keduanya yang dihitung.
7. Daftar Periksa
- [ ] Terdapat tepat satu sumber kebenaran; salinan lainnya dihasilkan oleh skrip;
- [ ] Skrip pembuatan berjalan di CI, dan perbedaan artefak apa pun menggagalkan build;
- [ ]
optionaldannullabledibedakan secara eksplisit dalam kontrak; - [ ] Field enum memiliki fallback untuk nilai tak dikenal;
- [ ] Integer besar, uang, dan waktu memiliki konvensi eksplisit di schema maupun tipe;
- [ ] File tipe yang dihasilkan membawa header "do not edit by hand".
FAQ
Bagaimana saya memutuskan antara schema-first dan types-first?
Pilih schema-first ketika backend memiliki kontraknya, membutuhkan penggunaan lintas bahasa, atau menerbitkan kontrak secara publik, dan hasilkan tipe dengan json-schema-to-typescript. Pilih library "schema adalah tipenya" ala zod ketika frontend memimpin dan ingin tipe serta validasi didefinisikan sekali, lalu ekspor JSON Schema. Jalur mana pun membutuhkan langkah CI yang membandingkan artefak yang dihasilkan dengan file repositori.
Mengapa definisi tipe dan schema yang ditulis manual selalu menyimpang?
Tipe dihapus saat kompilasi sementara schema hanya berlaku saat runtime, dan keduanya diedit oleh orang berbeda pada waktu berbeda. Tanpa pembuatan dan perbandingan, satu salinan diperbarui sementara yang lain tetap diam, yang menghasilkan "ia terkompilasi, lalu mengembalikan 500 saat runtime".
Haruskah saya meng-commit file tipe yang dihasilkan ke repositori?
Ya. Meng-commit mereka memungkinkan peninjau melihat perubahan kontrak, dan CI dapat membandingkan keluaran pembuatan baru dengan file repositori serta gagal pada perbedaan apa pun. Tinggalkan artefak hanya di folder lokal atau cache build dan tidak ada yang akan tahu tiga bulan kemudian apakah mereka masih terkini.