+3

"TypeScript Gymnastics": Đoạn code khiến tôi mất ngủ và sự giác ngộ về infer

Chào anh em thiện lành! Nếu anh em đã theo dõi những bài trước của mình về Architecture, Authz hay Caching, thì hôm nay chúng ta sẽ đổi gió một chút. Hãy thắt dây an toàn vào, vì chúng ta sắp lao xuống một trong những cái hố sâu nhất của thế giới Frontend/NodeJS: Advanced TypeScript (Type-Level Programming).

Vừa rồi, mình lướt Github và đọc source code của một thư viện CLI nổi tiếng, và mình khựng lại chừng 5 phút trước một đoạn code trông như một bài thơ... tà đạo. Nó đây:

// prettier-ignore
type InferredOptionTypeInner<O extends Options | PositionalOptions> = O extends
    { type: "array"; choices: ReadonlyArray<infer C> } ? C[]
    : O extends { type: "array"; string: true } ? string[]
    : O extends { type: "array"; number: true } ? number[]
    // ... (và hàng chục dòng tương tự) ...
    : O extends { type: "boolean" } ? boolean
    : O extends { type: "number" } ? number
    : O extends { type: "string" } ? string
    : unknown;

Lúc mới chập chững viết TS, nhìn thấy đống này chắc chắn mình sẽ chửi thề và gõ any cho xong chuyện. Nhưng hôm nay, ở vị trí của một người đã từng "đổ máu" vì mất autocomplete, mình sẽ giải phẫu đoạn code quái đản này để anh em thấy: TypeScript không chỉ là một ngôn ngữ, nó là một ngôn ngữ lập trình... dùng để lập trình ra các Type.

1. Mở bài: Nỗi đau mang tên "Mất Autocomplete" khi làm Library/Wrapper

Câu chuyện bắt đầu vào năm ngoái, khi team mình quyết định tự build một cái thư viện Form Validator (giống giống Yup hay Zod).

Ý tưởng rất hay. Ở phía khai báo (Config), anh em Dev sẽ truyền vào một object định nghĩa các field:

const schema = {
    age: { type: "number" },
    isAdmin: { type: "boolean" },
    role: { choices: ['admin', 'user'] }
};

Sau khi chạy hàm parse(schema), mình muốn kết quả trả về phải là một object mà khi gõ result., VSCode phải gợi ý age là number, isAdmin là boolean.

Nhưng đời không như mơ! Hàm parse() của mình trả về kiểu any (hoặc Record<string, any>). Dev code xong vẫn phải "nhắm mắt" gõ bừa, chẳng có tí intellisense nào. Cách duy nhất hồi đó là bắt anh em tự định nghĩa thêm một cái Interface bằng tay: interface User { age: number; isAdmin: boolean }.

Vậy là vi phạm nguyên tắc DRY (Don't Repeat Yourself)! Định nghĩa cấu trúc 1 lần ở biến schema rồi, sao lại phải gõ lại lần nữa ở Interface?

2. Hành trình giác ngộ: "Thể dục dụng cụ" với TypeScript

Và rồi mình phát hiện ra Conditional Types (Type điều kiện) và từ khóa infer - bộ đôi vũ khí tạo nên cái mớ bòng bong anh em thấy ở đầu bài. Ở cộng đồng quốc tế, người ta gọi kỹ năng viết những type này là "TypeScript Gymnastics" (Uốn dẻo với TypeScript).

Thực chất, đoạn code lằng nhằng kia chỉ là một vòng lặp if-else khổng lồ, nhưng nó chạy lúc Compile (viết code), chứ không phải lúc Runtime.

Hãy "dịch" nó ra ngôn ngữ con người nhé:

1. Cú pháp extends ? : (Toán tử 3 ngôi của Type)

O extends { type: "number" } ? number : string

Nghĩa là: Nếu cái object config O của tao mà có chứa thuộc tính type mang giá trị là chữ "number", thì hãy trả về Type number. Nếu không, trả về Type string.

2. Phép thuật mang tên infer (Nội suy)

Nhìn vào dòng này, nó là tinh hoa của đoạn code:

O extends { choices: ReadonlyArray<infer C> } ? C : ...

Giả sử bạn truyền vào config: { choices: ['admin', 'user'] }. Từ khóa infer C giống như một cái máy hút bụi. Nó bảo TypeScript: "Ê, tao không biết trong cái array choices kia chứa type gì, mày tự 'móc' cái type bên trong ra, đặt tên nó là biến C cho tao!". Kết quả: C sẽ được TS tự động gán bằng Type Literal: 'admin' | 'user'. Đỉnh cao chưa?

3. Tại sao lại có dòng // prettier-ignore?

Vì Prettier (công cụ format code) rất ghét những cấu trúc nối đuôi dài thòng lòng như vậy. Nếu không có dòng này, Prettier sẽ tự động ép đống if-else kia thành một khối nham nhở, đứt đoạn cực kỳ khó đọc. Tác giả cố tình giữ lại format bậc thang để chúng ta nhìn nó giống hệt một lệnh switch-case.

Kết quả của mớ bòng bong này là gì? Sau khi setup xong cái Type InferredOptionTypeInner, lúc anh em xài thư viện, chỉ cần khai báo config, TypeScript sẽ chạy qua cái "bộ lọc if-else" khổng lồ này và tự động suy ra chính xác Data Type ở output. Bạn có Autocomplete 100% từ VSCode mà không cần tự viết thêm bất kỳ Interface nào!

3. Bài học rút ra (The Takeaways)

  1. Với App thường, đừng lạm dụng: Nếu bạn đang code chức năng nghiệp vụ (Business logic) bình thường, xin đừng viết những type ma trận thế này. Team của bạn sẽ nguyền rủa bạn vì không ai đọc hiểu nổi. Hãy giữ Type đơn giản.
  2. Với Library/Core, nó là Siêu năng lực: Nếu bạn đang viết thư viện (Core, UI Kit, Form Builder, API Wrapper) để các team khác dùng, thì việc áp dụng Advanced Type là bắt buộc. Bạn chịu khó "đổ máu" lúc viết Type, thì hàng trăm Dev khác dùng thư viện của bạn sẽ thấy sướng như tiên vì tính năng Autocomplete.
  3. TypeScript thực ra là 2 ngôn ngữ: Một nửa là Javascript (chạy lúc runtime), một nửa là Type-Level Language (ngôn ngữ logic để xử lý type lúc compile). Hiểu được điều này, bạn mới thực sự Master được TypeScript.

Lời kết

Nhìn lại những dòng code như InferredOptionTypeInner, mình vừa nể phục trí tuệ của các pháp sư tạo ra các thư viện 오픈소스 (open source), vừa tự thấy bản thân còn phải học hỏi rất nhiều. Nó biến sự lỏng lẻo của JavaScript thành một hệ thống cực kỳ chặt chẽ và an toàn.

Còn anh em thì sao? Trong các project hiện tại, anh em có bao giờ phải vận công viết những hàm Utility Types (như Omit, Pick, hoặc dùng infer) dài loằng ngoằng chưa? Hay đối với anh em, cuộc đời ngắn ngủi, cứ any mà phang cho kịp deadline?

Cùng comment chém gió phía dưới nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí