Penyegerakan Dua Hala Antara Schema dan Jenis TypeScript
Inti pati: Jenis dan Schema masing-masing meliputi satu segmen: satu pada masa kompil, satu pada masa jalan. Persoalannya bukan mana yang perlu dipilih, tetapi cara mengekalkan kedua-duanya selaras secara automatik.

Intinya: pilih satu sumber kebenaran dahulu — Schema-dahulu apabila backend memiliki kontrak, jenis-dahulu gaya zod apabila frontend memilikinya — kemudian jana salinan yang lain dan buat diff dalam CI; tulis dengan tangan hanya semantik yang tidak dapat diungkapkan oleh penjana, seperti jenis berjenama.
Jika anda menulis kedua-dua takrifan dengan tangan, mereka akan terpesong. Harga keterpesongan ialah "ia berkompil, kemudian memulangkan 500 pada masa jalan."
1. Mengapa menyimpan hanya satu salinan tidak berfungsi
| Keupayaan | Jenis TypeScript | JSON Schema |
|---|---|---|
| Semakan masa kompil | ✓ | ✗ |
| Pengesahan masa jalan | ✗ (jenis dipadamkan) | ✓ |
| Penggunaan merentas bahasa | ✗ | ✓ (Java/Go/Python semuanya boleh membacanya) |
| Penyiapan IDE dan pemfaktoran semula | ✓ | Sederhana |
| Penjanaan dokumentasi | Sederhana | ✓ (ekosistem OpenAPI) |
Input kepada API datang daripada rangkaian dan sentiasa unknown, jadi pengesahan masa jalan tidak boleh dilangkau; dan semasa menulis kod perniagaan anda masih memerlukan penyiapan jenis.
Pendekatan yang wajar ialah memilih satu sebagai sumber kebenaran dan menjana yang lain.
2. Laluan A: Schema dahulu
Sesuai untuk: kerja yang dipacu backend, penggunaan merentas bahasa, dan kontrak awam.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* Generate TypeScript type definitions from a JSON Schema.
* @param schemaPath Path to the schema file
* @param outputPath Path to the output file
* @returns A Promise that resolves when generation is finished
*/
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 konvensyen dalam output wajar diberi perhatian:
required: ["id"]→id: string; kunci yang tidak disenaraikan dalamrequired→id?: string;"type": ["string", "null"]→string | null;enum→ satu kesatuan jenis literal, jauh lebih tepat daripadastring;additionalProperties: falsemenentukan sama ada tandatangan indeks dijana.
Terdapat juga pilihan tanpa binaan, json-schema-to-ts, yang membuat inferens terus pada tahap jenis:
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 }
Kelebihannya ialah tiada langkah penjanaan dan jenis yang sentiasa selari dengan Schema; kelemahannya ialah Schema mesti ditulis sebagai as const, dan senario $ref yang kompleks berkompil dengan perlahan.
3. Laluan B: jenis dahulu
Sesuai untuk: kerja yang dipacu frontend dengan pustaka seperti zod / valibot, di mana "Schema ialah jenis".
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>;
/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
Di sini zod memainkan kedua-dua peranan — "takrifan jenis" dan "pengesahan masa jalan" — dan JSON Schema hanyalah artifak eksport. Ini ialah usaha paling sedikit secara keseluruhan, tetapi ingat dua perkara:
z.inferdan JSON Schema yang dijana tidak memetakan dengan tepat untukdefault,transform, danrefine;- Schema yang dijana mungkin mengandungi
$refdandefinitions, jadi semak sebelum menerbitkan sama ada ia sepadan dengan spesifikasi sasaran.
4. Arah songsang juga perlu disemak
Walau laluan mana yang anda pilih, tambah semakan CI: artifak yang dijana mesti sepadan dengan fail dalam repositori.
# Generate into a temp directory and compare; fail CI on any difference
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 tiada siapa yang tahu sama ada api.d.ts terkini.
5. Empat perangkap berfrekuensi tinggi
5.1 optional dan nullable bukan perkara yang sama
type A = { name?: string }; // the key may be absent
type B = { name: string | null }; // the key must exist, but the value may be null
Dari segi Schema:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
Penjana mengendalikan dua kes ini dengan cara yang sama sekali berbeza. Kontrak mesti menyatakan yang mana yang anda maksudkan; jika tidak data.name?.length frontend akan ranap pada null.
5.2 Enum yang berkembang
type Status = 'pending' | 'paid' | 'refunded';
Jika backend menambah 'cancelled' dan jenis frontend tidak dikemas kini, kod jatuh ke cabang default.
Medan seperti enum harus mengendalikan nilai tidak diketahui secara eksplisit:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Tarikh dan wang tidak boleh hanya menggunakan string
Dalam Schema, format: "date-time" hanyalah anotasi; dalam TypeScript ia hanya string.
Apabila anda memerlukan jaminan yang lebih kuat, gunakan "jenis berjenama":
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* Mark a string as an ISO timestamp.
* @param value A string whose format has already been verified
* @returns The branded timestamp string
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 Integer besar
type: "integer" tidak bersamaan dengan number apabila nilai melebihi 2^53. Tulis dalam kontrak sebagai:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. Proses pelaksanaan yang dicadangkan
- Takrifkan kontrak: mulakan dengan menulis struktur permintaan/respons sebagai Schema (atau zod);
- Jana jenis: jana
.d.tsdalam CI dan komit ke repositori supaya perbezaan mudah disemak; - Jana pengesah: kompil Schema yang sama menjadi fungsi pengesahan Ajv, dan panggil pada setiap titik masuk pelayan;
- Jana dokumentasi: hasilkan Markdown atau OpenAPI daripada Schema supaya dokumen tidak pernah menjadi lapuk secara manual;
- Regresi: jalankan fixture API melalui kedua-dua "semakan jenis" dan "pengesahan Schema"; hanya lulus kedua-duanya yang dikira.
7. Senarai Semak
- [ ] Terdapat tepat satu sumber kebenaran; salinan yang lain dijana oleh skrip;
- [ ] Skrip penjanaan berjalan dalam CI, dan sebarang perbezaan artifak menggagalkan binaan;
- [ ]
optionaldannullabledibezakan secara eksplisit dalam kontrak; - [ ] Medan enum mempunyai sandaran untuk nilai tidak diketahui;
- [ ] Integer besar, wang, dan masa mempunyai konvensyen eksplisit dalam kedua-dua Schema dan jenis;
- [ ] Fail jenis yang dijana membawa pengepala "jangan sunting dengan tangan".
FAQ
Bagaimana saya memutuskan antara Schema-dahulu dan jenis-dahulu?
Pilih Schema-dahulu apabila backend memiliki kontrak, memerlukan penggunaan merentas bahasa, atau menerbitkan kontrak secara terbuka, dan jana jenis dengan json-schema-to-typescript. Pilih pustaka gaya zod "Schema ialah jenis" apabila frontend menerajui dan mahu jenis serta pengesahan ditakrifkan sekali, kemudian eksport JSON Schema. Sama ada laluan memerlukan langkah CI yang membandingkan artifak yang dijana dengan fail repositori.
Mengapa takrifan jenis dan Schema yang ditulis dengan tangan selalu terpesong?
Jenis dipadamkan pada masa kompil manakala Schema hanya berkesan pada masa jalan, dan kedua-duanya disunting oleh orang berbeza pada masa berbeza. Tanpa penjanaan dan perbandingan, satu salinan dikemas kini manakala yang lain kekal, yang menghasilkan "ia berkompil, kemudian memulangkan 500 pada masa jalan".
Patutkah saya komit fail jenis yang dijana ke repositori?
Ya. Mengkomitnya membolehkan penyemak melihat perubahan kontrak, dan CI boleh membandingkan output penjanaan baharu dengan fail repositori dan gagal pada sebarang perbezaan. Biarkan artifak hanya dalam folder tempatan atau cache binaan dan tiada siapa akan tahu tiga bulan kemudian sama ada ia terkini.