JSON Toolkit

Đồng bộ hai chiều giữa Schema và kiểu TypeScript

Kết luận: Kiểu và schema mỗi thứ phủ một đoạn: một bên ở thời điểm biên dịch, một bên ở thời điểm chạy. Vấn đề không phải chọn bên nào, mà là làm sao giữ hai bên đồng bộ một cách tự động.

Trung cấpĐọc khoảng 11 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa cho Đồng bộ hai chiều giữa Schema và kiểu TypeScript

Chốt lại: trước hết chọn một nguồn chân lý — schema-first khi backend sở hữu hợp đồng, kiểu-first theo kiểu zod khi frontend sở hữu — rồi sinh bản còn lại và diff nó trong CI; chỉ viết tay những ngữ nghĩa mà bộ sinh không thể diễn đạt, như branded type.

Nếu bạn viết tay cả hai định nghĩa, chúng sẽ lệch nhau. Cái giá của sự lệch nhau là "biên dịch được, rồi trả 500 lúc chạy".

1. Tại sao chỉ giữ một bản không hiệu quả

Khả năng Kiểu TypeScript JSON Schema
Kiểm tra lúc biên dịch ✓ ✗
Xác thực lúc chạy ✗ (kiểu bị xóa bỏ) ✓
Dùng đa ngôn ngữ ✗ ✓ (Java/Go/Python đều đọc được)
Gợi ý và refactor trong IDE ✓ Tạm được
Sinh tài liệu Tạm được ✓ (hệ sinh thái OpenAPI)

Đầu vào của một API đến từ mạng và luôn là unknown, nên không thể bỏ qua xác thực lúc chạy; còn khi viết mã nghiệp vụ, bạn vẫn cần gợi ý kiểu. Cách hợp lý là chọn một bên làm nguồn chân lý và sinh ra bên còn lại.

2. Con đường A: schema trước

Phù hợp với: công việc do backend dẫn dắt, sử dụng đa ngôn ngữ và hợp đồng công khai.

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');
}

Có vài quy ước trong kết quả đầu ra đáng lưu ý:

  • required: ["id"] → id: string; các khóa không nằm trong required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → một union các literal type, chính xác hơn string rất nhiều;
  • additionalProperties: false quyết định xem có sinh index signature hay không.

Còn một lựa chọn không cần bước build, đó là json-schema-to-ts, suy luận trực tiếp ở cấp độ kiểu:

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 }

Ưu điểm là không có bước sinh mã và kiểu luôn đồng bộ với schema; nhược điểm là schema phải được viết dưới dạng as const, và các tình huống $ref phức tạp sẽ biên dịch chậm.

3. Con đường B: kiểu trước

Phù hợp với: công việc do frontend dẫn dắt, dùng các thư viện như zod / valibot, nơi "schema chính là kiểu".

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',
});

Ở đây zod đóng cả hai vai — "định nghĩa kiểu" và "xác thực lúc chạy" — còn JSON Schema chỉ là sản phẩm xuất ra. Đây là cách ít công sức nhất, nhưng cần lưu ý hai điều:

  • z.infer và JSON Schema sinh ra không ánh xạ chính xác với default, transform và refine;
  • Schema sinh ra có thể chứa $ref và definitions, nên trước khi công bố hãy kiểm tra xem nó có khớp đặc tả đích hay không.

4. Chiều ngược lại cũng cần kiểm tra

Dù chọn con đường nào, hãy thêm một bước kiểm tra trong CI: sản phẩm sinh ra phải khớp với các tệp trong kho mã.

# 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

Thiếu bước này, ba tháng sau sẽ không ai biết api.d.ts còn mới hay không.

5. Bốn cái bẫy tần suất cao

5.1 optional và nullable không phải là một

type A = { name?: string };        // the key may be absent
type B = { name: string | null };  // the key must exist, but the value may be null

Nói theo thuật ngữ schema:

{ "properties": { "name": { "type": "string" } } }                        // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable

Các bộ sinh xử lý hai trường hợp này hoàn toàn khác nhau. Hợp đồng phải nói rõ bạn muốn trường hợp nào; nếu không, data.name?.length phía front end sẽ vỡ trên null.

5.2 Enum tiến hóa

type Status = 'pending' | 'paid' | 'refunded';

Nếu backend thêm 'cancelled' mà kiểu frontend không được cập nhật, mã sẽ rơi vào nhánh default. Các trường kiểu enum nên xử lý giá trị không xác định một cách tường minh:

function normalizeStatus(raw: string): Status | 'unknown' {
  return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}

5.3 Ngày giờ và tiền không thể chỉ dùng string

Trong schema, format: "date-time" chỉ là một chú thích; trong TypeScript nó chỉ là string. Khi cần một bảo đảm mạnh hơn, hãy dùng "branded type":

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 Số nguyên lớn

type: "integer" không tương đương với number khi giá trị vượt quá 2^53. Hãy viết trong hợp đồng là:

{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }

6. Quy trình triển khai đề xuất

  1. Định nghĩa hợp đồng: bắt đầu bằng việc viết cấu trúc request/response dưới dạng schema (hoặc zod);
  2. Sinh kiểu: sinh .d.ts trong CI và commit vào kho mã để dễ soát thay đổi;
  3. Sinh bộ xác thực: biên dịch cùng schema thành hàm xác thực Ajv, và gọi nó ở mọi điểm vào phía máy chủ;
  4. Sinh tài liệu: xuất Markdown hoặc OpenAPI từ schema để tài liệu không bị lỗi thời do viết tay;
  5. Hồi quy: chạy fixture API qua cả "kiểm tra kiểu" lẫn "xác thực schema"; chỉ qua cả hai mới được tính.

7. Danh sách kiểm tra

  • [ ] Có đúng một nguồn chân lý; bản còn lại được sinh bằng script;
  • [ ] Script sinh mã chạy trong CI, và mọi khác biệt sản phẩm đều làm build fail;
  • [ ] optional và nullable được phân biệt rõ ràng trong hợp đồng;
  • [ ] Trường enum có phương án dự phòng cho giá trị không xác định;
  • [ ] Số nguyên lớn, tiền và thời gian có quy ước tường minh trong cả schema lẫn kiểu;
  • [ ] Tệp kiểu sinh ra có tiêu đề "không sửa tay".

FAQ

Làm sao quyết định giữa schema-first và types-first?

Chọn schema-first khi backend sở hữu hợp đồng, cần dùng đa ngôn ngữ hoặc công bố hợp đồng ra bên ngoài, rồi sinh kiểu bằng json-schema-to-typescript. Chọn thư viện kiểu zod "schema chính là kiểu" khi frontend dẫn dắt và muốn định nghĩa kiểu cùng xác thực một lần, rồi xuất JSON Schema. Con đường nào cũng cần một bước CI so sánh sản phẩm sinh ra với các tệp trong kho mã.

Tại sao định nghĩa kiểu và schema viết tay luôn lệch nhau?

Kiểu bị xóa bỏ lúc biên dịch còn schema chỉ có hiệu lực lúc chạy, và hai bên do những người khác nhau sửa ở những thời điểm khác nhau. Không có bước sinh mã và so sánh, một bản được cập nhật còn bản kia đứng yên, dẫn tới "biên dịch được, rồi trả 500 lúc chạy".

Có nên commit tệp kiểu sinh ra vào kho mã?

Có. Commit chúng giúp người soát mã thấy được thay đổi hợp đồng, và CI có thể so sánh kết quả sinh mới với tệp trong kho rồi fail khi có khác biệt. Nếu chỉ để sản phẩm trong thư mục cục bộ hoặc cache build, ba tháng sau sẽ không ai biết chúng còn mới hay không.