การซิงก์สองทางระหว่าง Schema และ TypeScript Type
สรุปสำคัญ: Type และ schema ต่างครอบคลุมคนละส่วน: หนึ่งตอน compile หนึ่งตอนรันไทม์ คำถามไม่ใช่ว่าจะเลือกอันไหน แต่จะทำให้ทั้งสองซิงก์กันโดยอัตโนมัติอย่างไร

สรุปสั้น: เลือกแหล่งความจริงเพียงหนึ่งก่อน — schema-first เมื่อ backend เป็นเจ้าของสัญญา, types-first แบบ zod เมื่อ frontend เป็นเจ้าของ — แล้วสร้างสำเนาอีกชุดและ diff ใน CI; เขียนเองเฉพาะความหมายที่ generator แสดงไม่ได้ เช่น branded type
ถ้าคุณเขียนคำจำกัดความทั้งสองด้วยมือ มันจะแยกออกจากกัน ราคาของการแยกคือ "compile ผ่าน แล้วคืน 500 ตอนรันไทม์"
1. ทำไมการเก็บสำเนาเดียวจึงใช้ไม่ได้
| ความสามารถ | TypeScript type | JSON Schema |
|---|---|---|
| การตรวจตอน compile | ✓ | ✗ |
| การตรวจสอบตอนรันไทม์ | ✗ (type ถูกลบทิ้ง) | ✓ |
| การใช้ข้ามภาษา | ✗ | ✓ (Java/Go/Python อ่านได้ทั้งหมด) |
| การเติมเต็มและ refactor ใน IDE | ✓ | พอใช้ |
| การสร้างเอกสาร | พอใช้ | ✓ (ระบบนิเวศ OpenAPI) |
อินพุตของ API มาจากเครือข่ายและเป็น unknown เสมอ การตรวจสอบตอนรันไทม์จึงข้ามไม่ได้; และขณะเขียนโค้ดธุรกิจคุณยังต้องการการเติมเต็ม type
แนวทางที่สมเหตุสมผลคือ เลือกหนึ่งเป็นแหล่งความจริงแล้วสร้างอีกชุด
2. เส้นทาง A: schema ก่อน
เหมาะกับ: งานที่ backend ขับเคลื่อน การใช้ข้ามภาษา และสัญญาสาธารณะ
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* สร้างคำจำกัดความ TypeScript จาก JSON Schema
* @param schemaPath พาธของไฟล์ schema
* @param outputPath พาธของไฟล์ผลลัพธ์
* @returns Promise ที่ resolve เมื่อสร้างเสร็จ
*/
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');
}
ธรรมเนียมบางอย่างในผลลัพธ์ที่ควรสังเกต:
required: ["id"]→id: string; คีย์ที่ไม่อยู่ในrequired→id?: string;"type": ["string", "null"]→string | null;enum→ union ของ literal type ซึ่งแม่นยำกว่าstringมาก;additionalProperties: falseกำหนดว่าจะสร้าง index signature หรือไม่
ยังมีตัวเลือก zero-build คือ json-schema-to-ts ที่อนุมานที่ระดับ type โดยตรง:
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 }
ข้อดีคือไม่มีขั้นการสร้างและ type ซิงก์กับ schema เสมอ; ข้อเสียคือ schema ต้องเขียนเป็น as const และสถานการณ์ $ref ที่ซับซ้อน compile ช้า
3. เส้นทาง B: types ก่อน
เหมาะกับ: งานที่ frontend ขับเคลื่อนด้วยไลบรารีอย่าง zod / valibot ซึ่ง "schema คือ type"
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>;
/** สัญญาที่เผยแพร่ให้ภาษาอื่น */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
ตรงนี้ zod เล่นบทบาททั้งสอง — "คำจำกัดความ type" และ "การตรวจสอบตอนรันไทม์" — และ JSON Schema เป็นเพียง artifact ที่ส่งออก นี่คือวิธีที่ใช้แรงน้อยที่สุดโดยรวม แต่ให้จำสองเรื่อง:
z.inferกับ JSON Schema ที่สร้างไม่แมปกันตรง ๆ สำหรับdefault,transformและrefine;- schema ที่สร้างอาจมี
$refและdefinitionsดังนั้นตรวจก่อนเผยแพร่ว่ามันตรงกับสเปกเป้าหมายหรือไม่
4. ทิศทางย้อนกลับก็ต้องตรวจเช่นกัน
ไม่ว่าจะเลือกเส้นทางใด ให้เพิ่มการตรวจใน CI: artifact ที่สร้างต้องตรงกับไฟล์ใน repository
# สร้างลงไดเรกทอรีชั่วคราวแล้วเปรียบเทียบ; ให้ CI ล้มเหลวเมื่อต่างกัน
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
ถ้าไม่มีขั้นนี้ สามเดือนต่อมาไม่มีใครรู้ว่า api.d.ts เป็นปัจจุบันหรือไม่
5. สี่กับดักที่เกิดบ่อย
5.1 optional กับ nullable ไม่ใช่เรื่องเดียวกัน
type A = { name?: string }; // คีย์อาจหายไป
type B = { name: string | null }; // คีย์ต้องมี แต่ค่าอาจเป็น null
ในแง่ schema:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
generator จัดการสองกรณีนี้ต่างกันโดยสิ้นเชิง สัญญาต้องระบุว่าคุณหมายถึงแบบไหน; มิฉะนั้น data.name?.length ของ frontend จะ crash เมื่อเจอ null
5.2 enum ที่พัฒนาไป
type Status = 'pending' | 'paid' | 'refunded';
ถ้า backend เพิ่ม 'cancelled' และ type ของ frontend ไม่อัปเดต โค้ดจะตกไปที่สาขา default
ฟิลด์คล้าย enum ควรจัดการค่าที่ไม่รู้จักอย่างชัดเจน:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 วันที่และเงินใช้ string ตรง ๆ ไม่ได้
ใน schema format: "date-time" เป็นเพียง annotation; ใน TypeScript มันคือ string ธรรมดา
เมื่อต้องการการรับประกันที่แข็งแรงกว่า ให้ใช้ "branded type":
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* ทำเครื่องหมายสตริงว่าเป็น timestamp แบบ ISO
* @param value สตริงที่ตรวจรูปแบบแล้ว
* @returns สตริง timestamp ที่ติดแบรนด์แล้ว
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 จำนวนเต็มขนาดใหญ่
type: "integer" ไม่เทียบเท่า number เมื่อค่าเกิน 2^53 เขียนในสัญญาดังนี้:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. กระบวนการนำไปใช้ที่แนะนำ
- กำหนดสัญญา: เริ่มจากการเขียนโครงสร้าง request/response เป็น schema (หรือ zod);
- สร้าง type: สร้าง
.d.tsใน CI แล้วคอมมิตเข้า repository เพื่อให้ตรวจส่วนต่างได้ง่าย; - สร้าง validator: compile schema เดียวกันเป็นฟังก์ชันตรวจสอบของ Ajv และเรียกที่จุดเข้าเซิร์ฟเวอร์ทุกจุด;
- สร้างเอกสาร: ส่งออก Markdown หรือ OpenAPI จาก schema เพื่อให้เอกสารไม่ล้าสมัยด้วยมือ;
- ถดถอย: รัน fixture ของ API ผ่านทั้ง "การตรวจ type" และ "การตรวจ schema"; ผ่านทั้งสองจึงนับว่าผ่าน
7. รายการตรวจสอบ
- [ ] มีแหล่งความจริงเพียงหนึ่งเดียว; สำเนาอีกชุดสร้างด้วยสคริปต์;
- [ ] สคริปต์การสร้างรันใน CI และส่วนต่างของ artifact ใด ๆ ทำให้ build ล้มเหลว;
- [ ]
optionalกับnullableถูกแยกอย่างชัดเจนในสัญญา; - [ ] ฟิลด์ enum มีทางถอยสำหรับค่าที่ไม่รู้จัก;
- [ ] จำนวนเต็มขนาดใหญ่ เงิน และเวลามีข้อตกลงชัดเจนทั้งใน schema และ type;
- [ ] ไฟล์ type ที่สร้างมีส่วนหัว "do not edit by hand"
คำถามที่พบบ่อย
จะตัดสินใจระหว่าง schema-first กับ types-first อย่างไร
เลือก schema-first เมื่อ backend เป็นเจ้าของสัญญา ต้องการใช้ข้ามภาษา หรือเผยแพร่สัญญาสาธารณะ และสร้าง type ด้วย json-schema-to-typescript เลือกไลบรารีแบบ zod ที่ "schema คือ type" เมื่อ frontend เป็นผู้นำและต้องการนิยาม type กับการตรวจสอบครั้งเดียว แล้วส่งออก JSON Schema ไม่ว่าเส้นทางใดต้องมีขั้น CI ที่เปรียบเทียบ artifact ที่สร้างกับไฟล์ใน repository
ทำไม type และ schema ที่เขียนด้วยมือจึงแยกออกจากกันเสมอ
type ถูกลบทิ้งตอน compile ขณะที่ schema มีผลเฉพาะตอนรันไทม์ และทั้งสองถูกแก้โดยคนละคนในเวลาต่างกัน ถ้าไม่มีการสร้างและการเปรียบเทียบ สำเนาหนึ่งจะถูกอัปเดตขณะที่อีกชุดหยุดนิ่ง ซึ่งสร้าง "compile ผ่าน แล้วคืน 500 ตอนรันไทม์"
ควรคอมมิตไฟล์ type ที่สร้างเข้า repository ไหม
ควร การคอมมิตทำให้ผู้ตรวจเห็นการเปลี่ยนสัญญา และ CI เปรียบเทียบผลการสร้างใหม่กับไฟล์ใน repository และล้มเหลวเมื่อต่างกัน ถ้าเก็บ artifact ไว้เพียงโฟลเดอร์ในเครื่องหรือ build cache สามเดือนต่อมาจะไม่มีใครรู้ว่ามันเป็นปัจจุบันหรือไม่