Record<K, V> là một utility type có sẵn cho object có khóa kiểu K và mọi giá trị đều kiểu V. Với khóa string, nó mô tả một từ điển (dictionary); với union các khóa literal, nó mô tả một object phải có đúng những khóa đó.
Record chỉ tồn tại trong hệ thống kiểu. Lúc chạy, cả hai object đều là object JavaScript thông thường, nên chúng hoạt động với object literal, spread, JSON.stringify và mọi thứ khác nhận object.
Cú pháp và định nghĩa
Record<Keys, Value>
Keys phải là thứ có thể làm khóa của object: string, number, symbol, một union các literal chuỗi hoặc số, hay một template literal type. Value có thể là kiểu bất kỳ. Toàn bộ định nghĩa trong thư viện chuẩn của TypeScript chỉ là một dòng, một mapped type:
type Record<K extends keyof any, T> = {
[P in K]: T;
};
keyof any là string | number | symbol, tập hợp mọi kiểu khóa có thể có. [P in K]: T tạo một thuộc tính kiểu T cho mỗi thành phần của K. Điều đó giải thích cả hai hành vi bên dưới: một K rộng như string tạo ra index signature (khóa bất kỳ), còn một K là union tạo ra một thuộc tính bắt buộc cho mỗi thành phần.
Khóa union: mọi khóa đều bắt buộc
Khi các khóa là union các literal, một Record phải liệt kê đủ từng khóa, và không có khóa nào khác. Điều này biến trình biên dịch thành một danh sách kiểm tra:
Thêm "cancelled" vào Status thì cả hai object sẽ không biên dịch được cho đến khi bạn đặt nhãn và màu cho trạng thái mới. Bỏ sót một khóa, hoặc thêm một khóa không có trong union, đều là lỗi biên dịch:
index.ts(4,7): error TS2741: Property 'error' is missing in type '{ idle: string; loading: string; success: string; }' but required in type 'Record<Status, string>'.
index.ts(11,54): error TS2353: Object literal may only specify known properties, and 'paused' does not exist in type 'Record<Status, string>'.
Cách này cũng hoạt động với string enum làm kiểu khóa: Record<Color, string> bắt buộc có một mục cho mỗi thành phần của enum.
Record<string, T> và khóa không tồn tại
Với khóa string, khóa nào cũng được chấp nhận, và TypeScript gán kiểu T cho mọi lần tra cứu, kể cả với khóa không có. Lúc chạy, khóa không tồn tại sẽ cho undefined:
Đây là bug phổ biến nhất với Record. Có ba cách xử lý: kiểm tra bằng in hoặc Object.hasOwn trước khi đọc, khai báo giá trị là V | undefined, hoặc bật tùy chọn trình biên dịch noUncheckedIndexedAccess, tùy chọn này thêm | undefined vào mọi lần đọc qua index signature trong dự án. Record với khóa union không gặp vấn đề này, vì mọi khóa đều chắc chắn tồn tại.
Partial<Record<K, V>>: chỉ một số khóa
Để dùng union các khóa mà không bắt buộc có tất cả, hãy bọc Record trong Partial. Khi đó phép đọc trả về V | undefined, phản ánh đúng thực tế:
Một khóa viết sai chính tả như jp vẫn là lỗi, đó là ưu điểm so với Record<string, string>.
Duyệt qua một Record
Object.keys, Object.values và Object.entries đều dùng được. Điểm cần lưu ý là kiểu của khóa: Object.keys trả về string[] và Object.entries trả về [string, V][], không bao giờ là union các khóa của bạn:
TypeScript cố ý giữ khóa ở kiểu string: lúc chạy một object có thể có nhiều thuộc tính hơn những gì kiểu của nó liệt kê, nên nói chung cam kết Plan[] là không an toàn. Với object bạn tạo từ một literal, như seats, phép ép kiểu là an toàn.
Tạo Record từ dữ liệu
Record thường là kết quả của việc nhóm hoặc đánh chỉ mục một mảng. Bắt đầu từ một object rỗng có kiểu Record rồi điền dữ liệu vào:
Book["genre"] dùng lại union từ interface làm kiểu khóa, nên thêm một thể loại vào Book sẽ khiến byGenre đòi thêm một mục mới.
Record<string, unknown> và interface
Record<string, unknown> là kiểu thường dùng cho "một object nào đó có khóa là chuỗi". Nó chấp nhận object literal và giá trị có kiểu là type alias, nhưng một interface thì bị từ chối:
index.ts(11,11): error TS2345: Argument of type 'User' is not assignable to parameter of type 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'User'.
Interface có thể được mở rộng qua cơ chế gộp khai báo, nên TypeScript không mặc định rằng chúng khớp với một index signature; type alias thì không thể mở lại, nên type User = { name: string } sẽ hợp lệ. Các cách sửa thường dùng là nhận object thay thế (bạn vẫn gọi được Object.keys trên nó), biến hàm thành generic (<T extends object>(obj: T)), hoặc khai báo User bằng type (trang interface và type trình bày các khác biệt còn lại).
So sánh Record, index signature và Map
Record<K, V> | { [key: string]: V } | Map<K, V> | |
|---|---|---|---|
| Tồn tại lúc chạy | không, là object thông thường | không, là object thông thường | có, là một lớp |
| Tập khóa cố định | có, với K là union | không | không |
| Kiểu khóa | string, number, symbol, union literal, template pattern | string, number, symbol, template pattern | bất kỳ, kể cả object |
| Kiểu khi tra cứu khóa không tồn tại | V (với khóa string) | V | V | undefined từ get |
| Kết hợp với thuộc tính có tên | qua intersection & | có, trong cùng một kiểu | không |
| JSON và spread | có | có | không, phải chuyển đổi trước |
| Kích thước | Object.keys(r).length | Object.keys(o).length | m.size |
| Thêm và xóa thường xuyên | được | được | được thiết kế cho việc này |
Chọn Record với khóa union mỗi khi tập khóa đã biết trước: đây là lựa chọn duy nhất kiểm tra được mọi khóa đều có mặt. Với khóa chuỗi không giới hạn, Record<string, V> và index signature có thể thay thế cho nhau, và nhiều dự án chọn Record vì dễ đọc hơn. Hãy dùng Map khi khóa được thêm và xóa lúc chạy, khi khóa không phải chuỗi, hoặc khi bạn cần kích thước và thứ tự thêm vào mà không phải làm thêm gì.
Câu hỏi thường gặp
Record trong TypeScript là gì?
Record<K, V> là một utility type có sẵn cho object có khóa kiểu K và mọi giá trị đều kiểu V. Record<string, number> là object với khóa là chuỗi bất kỳ và giá trị kiểu số; Record<"en" | "de", string> là object có đúng hai khóa en và de, cả hai đều là chuỗi.
Record và Map trong TypeScript khác nhau thế nào?
Record là kiểu cho một object JavaScript thông thường, nên nó hoạt động với object literal, JSON và spread, và biến mất khi biên dịch. Map là một lớp tồn tại lúc chạy với get, set, has và size, giữ thứ tự thêm vào cho mọi khóa, nhận khóa thuộc bất kỳ kiểu nào (cả object), và get trả về V | undefined. Hãy dùng Record cho dữ liệu cố định hoặc có dạng JSON, và Map cho các khóa được thêm, xóa lúc chạy.
Record<string, T> và { [key: string]: T } khác nhau thế nào?
Về giá trị, chúng là cùng một kiểu: Record<string, T> được mở rộng thành một kiểu object có string index signature. Có hai khác biệt nhỏ: index signature có thể mang tên và đứng cạnh các thuộc tính khác trong cùng một kiểu, và keyof Record<string, T> là string trong khi keyof { [key: string]: T } là string | number.
Làm sao duyệt qua một Record trong TypeScript?
Dùng Object.entries(record) để lấy các cặp khóa và giá trị, Object.keys để lấy khóa và Object.values để lấy giá trị. Khóa trả về có kiểu string, không phải K, vì lúc chạy object có thể chứa thêm khóa khác. Khi Record có union các khóa đã biết, hãy ép kiểu: (Object.keys(r) as Array<keyof typeof r>).
Làm sao chỉ bắt buộc một số khóa của Record?
Với khóa union, Record<K, V> bắt buộc mọi khóa. Bọc nó trong Partial để mọi khóa thành tùy chọn: Partial<Record<Lang, string>>. Để kết hợp cả hai, dùng intersection: Record<"en", string> & Partial<Record<"de" | "fr", string>> bắt buộc có en và cho phép các khóa còn lại.