Tạo kiểu TypeScript từ JSON của API, và vì sao any quay lại

Sinh interface TypeScript từ một phản hồi API thật, bổ sung những gì một mẫu không thể biết, và chặn any lọt vào ở ranh giới mạng.

Trong bài này

Viết tay interface cho một API JSON vừa chậm vừa dễ sai, nên phần lớn nhóm phát triển sinh bản nháp đầu tiên từ một phản hồi mẫu. Bản nháp đó hữu ích, nhưng nó chỉ là phỏng đoán dựa trên một ví dụ, và kiểu chỉ có tác dụng khi dữ liệu đến lúc chạy thật sự khớp với nó. Bài này sinh kiểu từ một phản hồi đơn hàng giống thật, chỉ ra công cụ sinh làm đúng điều gì và không thể biết điều gì, rồi xử lý vấn đề âm thầm hơn: những chỗ any lọt trở lại vào một mã nguồn trông như đã có kiểu đầy đủ. Mọi đầu ra dưới đây đều lấy từ công cụ JSON sang TypeScript.

Mẫu dữ liệu

Đây là một đơn hàng như API có thể trả về, chép từ tab Network của trình duyệt:

{
  "id": 1042,
  "status": "shipped",
  "total": 259.9,
  "paid_at": null,
  "customer": { "id": 7, "name": "Trần Minh An", "email": "[email protected]" },
  "items": [
    { "sku": "MUG-01", "qty": 2, "price": 89.95 },
    { "sku": "TEE-L", "qty": 1, "price": 80, "gift_note": "Happy birthday" }
  ],
  "tags": ["priority", "gift"],
  "shipping-address": { "city": "Đà Nẵng", "zip": null }
}

Công cụ sinh ra gì

export interface Root {
  id: number;
  status: string;
  total: number;
  paid_at: null;
  customer: Customer;
  items: Item[];
  tags: string[];
  "shipping-address": ShippingAddress;
}

export interface Item {
  sku: string;
  qty: number;
  price: number;
  gift_note?: string;
}

(Customer và ShippingAddress theo cùng mẫu.) Có ba quyết định đáng chú ý:

  1. Hai dòng hàng được gộp lại. Chỉ dòng thứ hai có gift_note, nên trường này thành tùy chọn. Một dòng có giá 89.95, dòng kia 80; cả hai đều là number, còn trong đầu ra Go trường này là float64 chứ không phải int64 vì có một mẫu mang số thập phân.
  2. Khóa có dấu gạch ngang được đặt trong nháy kép. "shipping-address" là tên thuộc tính hợp lệ, chỉ là không viết được nếu thiếu nháy, và bạn đọc nó bằng order["shipping-address"].
  3. `paid_at` có kiểu `null`. Trông thì sai nhưng đó là câu trả lời trung thực duy nhất: mọi mẫu đều có null ở đó. Công cụ không thể biết rằng đơn đã thanh toán sẽ mang một mốc thời gian.

Bổ sung những gì một mẫu không thể biết

Một mẫu chỉ cho thấy một phản hồi. Trước khi commit, hãy sửa kiểu theo hiểu biết của bạn về API:

  • Trường bị null trong mẫu. paid_at: null phải là paid_at: string | null. Cách nhanh nhất để có điều này tự động là dán hai đơn hàng trong một mảng, một đơn chưa trả và một đơn đã trả; công cụ gộp chúng thành string | null.
  • Chuỗi có tập giá trị cố định. status: string chấp nhận cả "shiped". Hãy thay bằng status: "pending" | "paid" | "shipped" | "cancelled", trình biên dịch sẽ bắt lỗi gõ nhầm và chỉ ra nhánh nào một câu switch còn thiếu.
  • Ngày tháng. JSON không có kiểu ngày, nên mốc thời gian đến dưới dạng chuỗi. Giữ chúng là string trong kiểu truyền tải và đổi sang Date ở một chỗ duy nhất, đừng giả vờ API gửi đối tượng Date.
  • Mã định danh. id: number ổn cho đến khi ID vượt 2^53 − 1 (9.007.199.254.740.991), điều mà một số cơ sở dữ liệu dùng khóa 64 bit có thể chạm tới. Những ID đó phải được gửi dưới dạng chuỗi, vì số của JavaScript không giữ chính xác được.
  • Tên. Root nên là Order. Đặt tên kiểu gốc trong công cụ trước khi sinh để các tên lồng bên trong cũng dễ đọc.

Vì sao any vẫn quay lại

Sau tất cả công sức đó, người ta hay phát hiện các kiểu chẳng bảo vệ được gì, vì giá trị chảy vào chúng chưa bao giờ được kiểm tra. Đây là những chỗ rò thường gặp:

  • `response.json()` trả về `Promise<any>`. Định nghĩa kiểu của DOM khai báo như vậy. Nên const order: Order = await res.json() vẫn biên dịch dù máy chủ gửi gì; chú thích kiểu ở đây là một lời khẳng định, không phải phép kiểm tra.
  • `JSON.parse` trả về `any` vì cùng lý do, và việc đọc từ localStorage qua nó cũng vậy.
  • Ép kiểu bằng `as`. data as Order làm trình biên dịch im lặng mà không nhìn vào data.
  • Kiểu tiện ích lỏng lẻo. Một Record<string, any> dùng chung hay một hàm kiểu (x: any) => void lây any sang mọi thứ nó chạm vào, và truy cập thuộc tính của any lại cho ra any.
  • Thư viện không có kiểu. Thư viện thiếu kiểu, hoặc có any trong chữ ký công khai, đưa thẳng nó vào mã của bạn.

Cách sửa là coi mạng là nguồn không đáng tin. Gán kiểu unknown cho giá trị thô, buộc phải kiểm tra trước khi dùng, và xác thực một lần ở ranh giới. Đầu ra Zod của công cụ làm đúng việc đó:

const order = OrderSchema.parse(await res.json()); // ném lỗi nếu sai hình dạng
// từ đây order có kiểu đầy đủ, và kiểu với phép kiểm tra không thể lệch nhau

Sau đó để trình biên dịch và linter giúp sức: chế độ strict (gồm noImplicitAny và, từ TypeScript 4.4, useUnknownInCatchVariables), cùng các quy tắc của typescript-eslint là no-explicit-any và họ no-unsafe-*, vốn cảnh báo khi một giá trị any được gán, gọi hoặc trả về.

Những lỗi hay gặp

  • Sinh kiểu từ một bản ghi duy nhất. Một bản ghi không thể cho thấy trường tùy chọn hay trường null có giá trị. Hãy dán nhiều bản ghi, tốt nhất là các trường hợp biên: danh sách rỗng, đơn bị hủy, khách hàng không có địa chỉ.
  • Coi kiểu được sinh là hợp đồng. Nếu API công bố tài liệu OpenAPI hoặc JSON Schema, hãy sinh từ đó; một mẫu chỉ là phương án dự phòng.
  • Dùng `{}` cho đối tượng rỗng. Trong TypeScript {} nghĩa là "mọi giá trị khác null". Công cụ viết Record<string, unknown> thay vào đó.
  • Đặt tên kiểu trùng kiểu có sẵn. Khóa date sẽ cho ra interface Date, gộp với Date của JavaScript trong toàn chương trình. Công cụ đổi thành DateType.
  • Mặc định là camelCase. Nhiều API gửi snake_case. Đổi tên thuộc tính trong kiểu mà không chuyển đổi dữ liệu sẽ cho bạn kiểu của những khóa không hề tồn tại.

Thử ngay

Dán một phản hồi từ API của bạn vào công cụ JSON sang TypeScript, chuyển qua lại giữa TypeScript, Zod và Go để xem mỗi ngôn ngữ ghi lại cùng các sự thật thế nào, và nếu bộ phân tích báo lỗi, hãy kiểm tra JSON trong công cụ định dạng JSON trước.

Công cụ được nhắc trong bài

Về trang blog