Bạn viết một type để duyệt qua các object lồng nhau, xây dựng các đường dẫn phân tách bằng dấu chấm để hỗ trợ autocomplete. Nó hoạt động rất mượt mà trên một object test nhỏ. Nhưng khi bạn áp dụng nó vào một payload API thực tế, trình soạn thảo sẽ bị treo. Cuối cùng, TypeScript sẽ báo lỗi TS2589: Type instantiation is excessively deep and possibly infinite.

Thông báo này không có nghĩa là mã của bạn chứa một vòng lặp vô hạn theo nghĩa truyền thống. Nó có nghĩa là trình biên dịch đã bỏ cuộc. Type mà bạn yêu cầu tính toán hoặc là không có giới hạn thực sự, hoặc là hữu hạn nhưng quá lớn đến mức việc đánh giá nó sẽ làm cạn kiệt các giới hạn nội bộ của TypeScript. Khi điều này xảy ra, trình biên dịch sẽ dừng lại trước khi làm treo IDE của bạn.

Khi TS2589 xuất hiện

Các type đệ quy là thủ phạm phổ biến nhất. TypeScript đánh giá các type một cách quyết liệt (eagerly), và nếu một utility type liên tục tự gọi chính nó—đặc biệt là thông qua logic điều kiện—ngăn xếp tính toán (computation stack) sẽ tăng lên rất nhanh. Bạn thường sẽ gặp phải rào cản này trong một vài kịch bản cụ thể:

  • Các conditional type đệ quy liên tục destructure một tuple, object hoặc string template cho đến khi đạt đến trường hợp cơ sở (base case)
  • Các bộ tạo đường dẫn object lồng nhau sâu, chuyển đổi các cấu trúc như { user: { address: { street: string } } } thành các union của string literal như "user" | "user.address" | "user.address.street"
  • Các template literal type phân tích chuỗi theo từng ký tự hoặc từng token
  • Các mapped type lặp qua các object có hàng chục key và nhiều cấp độ
  • Các conditional type phân phối trên các union lớn, âm thầm nhân bản khối lượng công việc lên mọi thành phần

Ví dụ về đường dẫn lồng nhau đặc biệt dễ gây "cám dỗ". Các thư viện form và công cụ quản lý state rất thích cung cấp các đường dẫn đã được định kiểu (typed paths) để bạn có thể autocomplete tên các field. Với một object nông, việc tạo ra mọi dot-path hợp lệ dưới dạng một string union là việc đơn giản. Nhưng với một object sâu hoặc rộng, union đó sẽ bùng nổ. TypeScript phải giữ mọi hoán vị trong bộ nhớ làm việc cùng một lúc. Tại một độ sâu nhất định, trình biên dịch nhận thấy khối lượng công việc đang vượt quá ngân sách cho phép và sẽ kéo phanh khẩn cấp.

Cách khắc phục 1: Thêm giới hạn độ sâu cố định

Cách trực tiếp nhất để giải quyết TS2589 là ngừng giả vờ rằng type của bạn có thể đệ quy mãi mãi. Hãy đưa vào một bộ đếm độ sâu đóng vai trò như một cầu dao ngắt mạch (circuit breaker).

Trong thực tế, điều này có nghĩa là thêm một tham số generic kiểu số—thường được biểu diễn dưới dạng một tuple có độ dài giảm dần—giảm đi mỗi khi type đệ quy. Khi bộ đếm chạm mức 0, type sẽ trả về một giá trị fallback rộng hơn như string thay vì tiếp tục đào sâu hơn. Người dùng vẫn nhận được autocomplete chính xác cho bốn hoặc năm cấp độ đầu tiên, điều này bao quát phần lớn các object trong thế giới thực. Vượt quá mức đó, trình biên dịch chỉ đơn giản là mở rộng type (widen the type) và tiếp tục công việc.

Cách tiếp cận này không làm cho utility type của bạn kém chính xác đi theo bất kỳ cách có ý nghĩa nào. Nó chỉ làm cho nó có giới hạn. Một hệ thống type làm sập trình biên dịch thì không hữu ích hơn một hệ thống biết nhượng bộ một cách hợp lý sau một độ sâu hợp lý.

Cách khắc phục 2: Kiểm tra từng đường dẫn một

Nếu việc tạo ra mọi đường dẫn có thể ngay từ đầu quá tốn kém, hãy thay đổi "hợp đồng" (contract). Thay vì tạo ra một union khổng lồ của tất cả các chuỗi hợp lệ, hãy viết một type để kiểm tra xem một chuỗi cụ thể có phải là một đường dẫn hợp lệ hay không.

Hãy nghĩ về sự khác biệt giữa việc tạo ra một cuốn từ điển chứa mọi từ tiếng Anh so với việc kiểm tra xem một từ duy nhất có được đánh vần đúng hay không. Cái trước là một cấu trúc dữ liệu khổng lồ; cái sau là một thao tác quét nhẹ nhàng. Theo thuật ngữ TypeScript, thay vì xuất ra một utility Paths<T> trả về "user.address.street" | "user.settings.theme" | ..., bạn xuất ra thứ gì đó như IsValidPath<T, "user.address.street">. Trình biên dịch chỉ đánh giá đường dẫn mà bạn thực sự truyền vào.

Sự thay đổi này làm thay đổi cách bạn thiết kế API. Chữ ký hàm (function signatures) của bạn có thể chấp nhận một chuỗi và sau đó sử dụng một ràng buộc generic (generic constraint) để xác minh nó với cấu trúc của object. IDE vẫn sẽ báo lỗi nếu lập trình viên nhập một đường dẫn sai, nhưng trình biên dịch không bao giờ phải hiện thực hóa toàn bộ tập hợp các đường dẫn hợp lệ trong quá trình kiểm tra kiểu. Đối với các object lớn, sự khác biệt về hiệu suất là cực kỳ lớn.

Các chiến thuật nhanh giúp bạn tiếp tục công việc

Ngoài hai cách khắc phục về mặt cấu trúc, một vài thói quen nhỏ hơn có thể giúp các type đệ quy không vượt quá giới hạn:

  • Bọc các tham số kiểu trong tuple để chặn việc phân phối (distribution). Một tham số kiểu trần trong một câu lệnh điều kiện, như T extends Foo ? Bar : Baz, sẽ phân phối việc kiểm tra qua từng thành viên khi T là một union. Nếu union đó có năm mươi thành viên, TypeScript sẽ thực hiện năm mươi lần khởi tạo riêng biệt. Việc viết [T] extends [Foo] ? Bar : Baz sẽ đánh giá câu lệnh điều kiện một lần duy nhất cho toàn bộ union. Hãy sử dụng cách này bất cứ khi nào bạn không thực sự cần kiểu đó phải ánh xạ qua từng thành viên của union một cách riêng lẻ.

  • Thu nhỏ đầu vào khi đang debug. Khi lỗi TS2589 xuất hiện, hãy thay thế kiểu object thực tế (production object type) bằng một stub nhỏ gọn với hai thuộc tính và một cấp độ lồng nhau. Nếu lỗi biến mất, bạn đã xác nhận rằng vấn đề nằm ở độ sâu (depth) hoặc số lượng phần tử (cardinality), chứ không phải lỗi cú pháp. Điều này giúp bạn tránh việc phải viết lại những logic vốn dĩ đã ổn về mặt cấu trúc.

  • Làm mềm các kiểu API hướng tới người dùng (public-facing API types). Về mặt nội bộ, bạn có thể cần sự chính xác tuyệt đối. Nhưng về mặt bên ngoài, sự hoàn hảo đôi khi gây ra cái giá đắt hơn lợi ích nó mang lại. Nếu một kiểu autocomplete rộng hơn một chút giúp ngăn chặn tình trạng giật lag hai giây trong trình soạn thảo, thì sự đánh đổi đó thường là xứng đáng. Bạn có thể kết hợp kiểu lỏng lẻo hơn với một trình xác thực runtime (runtime validator) để bắt các trường hợp sai trong quá trình kiểm thử.

Tại sao TypeScript lại áp đặt ranh giới này

TypeScript không thể giải quyết bài toán dừng (halting problem). Nó không biết liệu kiểu đệ quy của bạn cuối cùng sẽ kết thúc hay sẽ xoáy vào vòng lặp vô tận. Thay vì mạo hiểm gây ra vòng lặp vô hạn bên trong trình biên dịch, nó áp dụng một điểm cắt (cutoff) thận trọng. Đôi khi điểm cắt đó chặn mất một kiểu dữ liệu mà lẽ ra sẽ hoàn thành nếu có đủ thời gian. TS2589 là cách trình biên dịch thừa nhận rằng nó thà chọn sự an toàn còn hơn là phải hối tiếc.

Tôn trọng giới hạn đó là một phần của việc viết các kiểu dữ liệu chuẩn production. Một định nghĩa kiểu thực chất là mã chạy trong trình biên dịch, và mã tốn kém sẽ gây ra những hậu quả thực tế. Tính năng autocomplete chậm làm giảm tốc độ làm việc của lập trình viên cũng giống như mã runtime chậm làm ảnh hưởng đến trải nghiệm người dùng vậy.

Bài học thực tế

TS2589 không phải là dấu hiệu cho thấy bạn là một lập trình viên hệ thống kiểu (type-system programmer) tồi. Đó là dấu hiệu cho thấy kiểu của bạn đang xử lý quá nhiều việc cùng một lúc. Hãy giới hạn đệ quy, xác thực một cách lười biếng (validate lazily) và phòng tránh việc phân phối không cần thiết. Mục tiêu của các kiểu nâng cao không phải là chứng minh mọi sự thật có thể có tại thời điểm biên dịch; mà là cung cấp cho đội ngũ của bạn các công cụ nhanh chóng và đáng tin cậy. Một kiểu dữ liệu biên dịch trong vài mili giây và bao quát được 95% các trường hợp sẽ có giá trị hơn nhiều so với một kiểu hoàn hảo về mặt lý thuyết nhưng lại làm treo language server.