JSON sang TypeScript: interface, type guard và giới hạn của sinh mã
Kết luận: Sinh interface từ JSON mẫu chỉ là nửa việc: trường tùy chọn, null, union, ngày tháng và số nguyên lớn cần con người xem lại, và kiểu không hề kiểm chứng runtime.

Kết luận: hãy sinh interface để tiết kiệm việc gõ, sau đó kiểm tra lại tính tùy chọn và null, giữ ngày tháng cùng số nguyên lớn dưới dạng chuỗi, và thêm kiểm chứng runtime ở ranh giới; coi kiểu đã sinh là hợp đồng đồng nghĩa với việc không có kiểm chứng nào cả.
Trình sinh chỉ thấy đúng một mẫu bạn dán vào. Việc trường có thể thiếu hay không, giá trị có thể là null hay không, và ngày tháng dùng định dạng nào đều là phỏng đoán — và những phỏng đoán sai là của bạn phải sửa.
1. Trình sinh thực sự làm gì
Dán một mẫu JSON vào JSON sang TypeScript và interface bạn nhận lại là một bản tóm tắt hình dạng: nó đếm các khóa và kiểu giá trị nó đã thấy, rồi chọn cách biểu diễn hẹp nhất có sẵn.
{
"id": 1,
"email": "ada@example.com",
"profile": { "nickname": "Ada", "age": 36 },
"tags": ["admin", "beta"],
"createdAt": "2026-10-31T08:00:00Z"
}
Kết quả trông đại khái như thế này:
interface User {
id: number;
email: string;
profile: { nickname: string; age: number };
tags: string[];
createdAt: string;
}
Đoạn này biên dịch được, nhưng nó mô tả một giá trị lý tưởng, không phải hợp đồng thật của endpoint. Trình sinh không biết email có thể thiếu hay không, tags có thể rỗng hay không, hay createdAt luôn mang múi giờ hay không. Nó thậm chí không biết id có thể là chuỗi trong các bản ghi khác.
Nên kỳ vọng đúng là: trình sinh tiết kiệm việc gõ, con người cung cấp ngữ nghĩa.
2. Trường tùy chọn và null: ? không phải | null
Đây là phần của đầu ra sinh ra bị sao chép nhiều nhất, và cũng là phần vỡ nhiều nhất.
| Dạng TypeScript | Ý nghĩa | JSON tương ứng |
|---|---|---|
email: string |
Luôn tồn tại, luôn là chuỗi | {"email":"a@b.c"} |
email?: string |
Khóa có thể vắng; khi tồn tại thì không thể là null | {} hoặc {"email":"a@b.c"} |
email: string | null |
Khóa luôn tồn tại, giá trị có thể là null | {"email":null} |
email?: string | null |
Khóa có thể vắng và giá trị có thể là null | Cả ba trường hợp |
Trình sinh chỉ thấy một mẫu, nên nó thường xuất ra dạng đầu tiên. Nếu API thực sự có thể trả null, kiểu đang nói dối: mọi thứ biên dịch được, rồi email.toLowerCase() ném lỗi lúc chạy.
Có hai cách sửa:
- Thu thập nhiều mẫu thật bao phủ cả "khóa thiếu" và "giá trị là null", rồi để trình sinh suy luận lại.
- Sau khi sinh, sửa tay các trường thành
?: T | nullvà thực sự xử lý cả hai nhánh trong mã.
Đừng đi đường tắt đánh dấu mọi thứ là ?: string. Tùy chọn nghĩa là "khóa có thể không tồn tại", không phải "giá trị có thể rỗng". undefined và null cũng hành xử khác nhau trong JSON.stringify: cái trước bị bỏ qua, cái sau được giữ là null.
3. Phần tử mảng không đều và kiểu union
Mảng là cấu trúc dễ bị làm sai nhất. Nếu tags xuất hiện như ["admin"], [], và ["beta", 3] qua ba bản ghi, trình sinh có thể phát ra string[], (string | number)[], hoặc unknown[], tùy vào mẫu nó đã thấy.
Con người phải quyết định:
- Kiểu phần tử thực sự hỗn hợp: viết
(string | number)[]và narrow tại điểm dùng. - Chỉ một bản ghi bẩn: sửa dữ liệu. Đừng che nó bằng
any[]. - Mảng rỗng: trình sinh không thể suy ra kiểu phần tử và rơi về
never[]hoặcany[], nên bạn phải tự điền vào. - Tuple độ dài cố định: chỉ dùng
[string, string]khi nghiệp vụ thực sự đảm bảo độ dài; nếu không, thêm một phần tử sau này sẽ thành lỗi biên dịch.
Mảng object cũng cần chú ý khi khóa không đều:
interface OrderItem {
sku: string;
quantity: number;
discount?: number; // some records do not carry this field
}
Đặt tính tùy chọn trên kiểu phần tử, không phải trên mảng. items?: OrderItem[] nghĩa là bản thân mảng có thể vắng, một khẳng định hoàn toàn khác.
4. Ngày tháng, số nguyên lớn, enum: những kiểu JSON không có
JSON chỉ có chuỗi và số; ý nghĩa nghiệp vụ nằm trong quy ước. Trình sinh suy ra từ literal, nên bạn phải sửa tay:
| Literal JSON | Đầu ra sinh ra thường gặp | Nên là gì |
|---|---|---|
"2026-10-31T08:00:00Z" |
string |
Giữ string ở tầng vận chuyển và chuyển đổi tại điểm dùng; đừng bao giờ khai báo Date trực tiếp |
1717075200000 |
number |
Dùng number cho timestamp mili giây và ghi rõ đơn vị |
"1234567890123456789" |
string |
Giữ là string để number không mất độ chính xác |
1 và "1" lẫn lộn |
number | string |
Chuẩn hoá về một dạng; đừng đẩy union vào mã nghiệp vụ |
"admin" |
string |
Union literal 'admin' | 'editor', hoặc hằng enum dùng chung |
true và "true" lẫn lộn |
boolean | string |
Sửa nguồn dữ liệu thay vì sinh ra kiểu này |
Quy tắc quan trọng nhất: ngày trong JSON luôn là chuỗi. Viết createdAt: Date là một cách tự lừa mình phổ biến — JSON.parse không bao giờ tạo Date, nên giá trị runtime vẫn là chuỗi và .getTime() sẽ ném lỗi. Giữ kiểu vận chuyển là string, gọi new Date(value) một cách tường minh ở ranh giới, và kiểm tra Number.isNaN.
Số nguyên lớn cũng vậy. Một khi id vượt 2^53, JSON.parse đã mất độ chính xác, và sinh number chỉ đóng đinh lỗi vào kiểu. Backend trả về chuỗi, frontend giữ nó là chuỗi; đó là lựa chọn đáng tin duy nhất.
5. Kiểu đã sinh không có kiểm chứng runtime
Kiểu TypeScript bị xoá ở thời điểm biên dịch. Interface chính xác đến đâu cũng không thể chặn một phản hồi {"id": "x"} — nó ràng buộc mã của bạn, không ràng buộc các byte đến qua mạng.
/**
* Check whether a runtime value is a user object.
* @param value The runtime value to validate
* @returns Whether it satisfies the minimum shape of User
*/
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}
Type guard chỉ bao phủ những trường bạn viết vào kiểm tra. Khi số trường nhân lên, guard viết tay trở thành định nghĩa thứ hai phải bảo trì, dần lệch khỏi interface.
Đánh đổi thực tế trông như sau:
| Cách | Kiểm chứng runtime | Nguồn kiểu | Phù hợp |
|---|---|---|---|
| Chỉ interface | Không | Viết tay hoặc sinh ra | Dữ liệu nội bộ đáng tin, ràng buộc thời điểm biên dịch |
| Interface cộng guard viết tay | Có | Hai định nghĩa lệch nhau | Ít trường, ranh giới rõ ràng |
| zod / valibot | Có | Kiểu suy ra từ schema | API bên ngoài, form, cấu hình |
| JSON Schema cộng validator | Có | Kiểu sinh từ schema | Hợp đồng đa ngôn ngữ, tài liệu |
Nếu dữ liệu đến từ bên ngoài, hãy coi schema là nguồn chân lý duy nhất và suy ra kiểu bằng z.infer hoặc json-schema-to-ts. Khi đó "kiểm chứng qua" và "kiểu đúng" là cùng một sự thật, và không có định nghĩa thứ hai để lệch. Với cách đồng bộ hai chiều đầy đủ, xem Đồng bộ Schema và kiểu TypeScript.
6. Interface hay type: đừng tranh luận về đầu ra sinh ra
Trình sinh thường phát ra interface, nhưng hai thứ này thay thế được cho nhau trong hầu hết tình huống. Điều thực sự quan trọng là ba năng lực:
- Declaration merging:
interfacecó thể được khai báo nhiều lần và tự động hợp nhất;typethì không. Điều này hữu ích khi vá kiểu bên thứ ba, nhưng mô hình nghiệp vụ nên tránh — hợp nhất cho phép "trường thừa" lọt qua compiler mà không ai nhận ra. - Union và mapped type:
typebiểu diễn trực tiếp đượcA | B,Partial<T>, và template literal type;interfacethì không. - Đệ quy: cả hai đều hỗ trợ, nhưng hình dạng đệ quy phức tạp đọc tự nhiên hơn khi là
type.
Một quy tắc nhóm hữu ích hơn: dùng interface cho hình dạng object, type cho union, utility type và function type. Hãy để đầu ra sinh ra ở mặc định và dành thời gian review cho tính tùy chọn và kiểm chứng runtime thay vào đó.
7. Toolchain: ba cách sinh
| Cách | Đầu vào | Điểm mạnh | Giới hạn |
|---|---|---|---|
| JSON sang TypeScript của trang này | JSON mẫu | Dùng được ngay, xử lý cục bộ | Chỉ dựa trên mẫu hiện tại; cần narrow thủ công |
| quicktype | JSON / JSON Schema | Đầu ra đa ngôn ngữ, tích hợp CLI | Suy luận thận trọng, union có thể quá rộng |
| json-schema-to-typescript | JSON Schema | Dựa trên hợp đồng và tái lập được | Trước hết cần một schema đáng tin |
Một lệnh quicktype điển hình:
npx quicktype -s json -o user.ts user.json --just-types
--just-types chỉ phát ra kiểu và không có mã chuyển đổi runtime, phù hợp với dự án đã có tầng phân tích. Nếu schema do tay viết hoặc do backend tạo, json-schema-to-typescript phù hợp hơn vì nó tôn trọng required, enum và additionalProperties — những ràng buộc mà suy luận từ mẫu đơn giản là không thấy.
Việc sinh schema có bẫy riêng, như đánh dấu mọi trường là bắt buộc hoặc âm thầm biến null thành tùy chọn; xem những bẫy phổ biến của trình sinh JSON Schema.
8. Danh sách kiểm tra trước khi commit kiểu đã sinh
Hãy chạy qua năm mục này trước khi commit đầu ra:
- Chuẩn hoá tên trường: đừng để
userIdvàuser_idcùng tồn tại; hãy ánh xạ một lần ở lớp chuyển đổi. - Kiểm tra lại tính tùy chọn: so với phản hồi thật và tìm ra trường nào có thể thiếu hoặc có thể là
null. - Giữ ngày và id là chuỗi: ghi rõ đơn vị và định dạng trong kiểu, và phân tích tường minh tại điểm dùng.
- Giảm
anyvàunknown: chỗ nào trình sinh phát raany, hãy hoặc thêm kiểu, hoặc giữunknownvà thêm guard. - Thêm kiểm chứng runtime: kiểm chứng dữ liệu bên ngoài ít nhất một lần; đừng kỳ vọng interface bắt được dữ liệu bẩn.
Nếu dự án dùng nhiều thư viện JSON (JSON.parse, superjson, lossless-json), hành vi của chúng với số nguyên lớn và ngày tháng khác nhau, nên hãy xác nhận khi chọn. Xem Chọn thư viện phân tích JSON.
Vì sao trình sinh đánh dấu mọi trường tùy chọn là bắt buộc?
Nó chỉ thấy một mẫu, nên mọi khóa đã xuất hiện đều bị coi là luôn tồn tại. Hãy cung cấp nhiều mẫu bao phủ các trường hợp khác nhau, hoặc sinh từ JSON Schema — mảng required ở đó là nguồn có thẩm quyền.
Vì sao khai báo Date trong interface lại lỗi lúc chạy?
JSON.parse chỉ tạo ra chuỗi, số, boolean, null, object và array; nó không bao giờ tạo Date. Khai báo Date là một lời hứa ở thời điểm biên dịch trong khi giá trị runtime vẫn là chuỗi. Giữ string ở tầng vận chuyển và chuyển đổi ở ranh giới.
Có thể dùng kiểu đã sinh để kiểm chứng không?
Không. Kiểu TypeScript bị xoá sau khi biên dịch và không áp đặt gì lên dữ liệu runtime. Hãy kiểm chứng dữ liệu bên ngoài bằng zod, valibot, ajv, hoặc type guard viết tay.
Id số nguyên lớn nên đặt kiểu number hay string?
Nếu nó có thể vượt 2^53, phải là string. JSON.parse mất độ chính xác khi phân tích, và đặt kiểu number chỉ ghi lỗi vào kiểu nơi không còn sửa được nữa.