Utility Types trong TypeScript: Partial, Pick, Omit, Record
1. Tổng quan
Khi viết TypeScript, bạn sẽ gặp tình huống cần một type gần giống type đã có nhưng thiếu/thừa vài field. Thay vì khai báo interface mới, TypeScript cung cấp sẵn một nhóm utility types để biến đổi type hiện có. Bài này đi qua bốn utility type dùng nhiều nhất: Partial, Pick, Omit, Record, kèm ví dụ thực tế và vài lỗi hay gặp. Đọc xong, bạn sẽ biết chọn đúng utility type cho từng tình huống thay vì viết lại interface thủ công.
2. Partial<T>
Partial<T> biến tất cả field của T thành optional. Hữu ích khi viết hàm update, nơi caller chỉ cần truyền một phần field.
interface Customer {
id: number;
name: string;
email: string;
phone: string;
}
function updateCustomer(id: number, changes: Partial<Customer>): Customer {
const current = findCustomerById(id); // giả định hàm này đã tồn tại
return { ...current, ...changes };
}
updateCustomer(1, { email: "new@mail.com" });
Partial<Customer> tạo ra type tương đương:
interface PartialCustomer {
id?: number;
name?: string;
email?: string;
phone?: string;
}
Nhờ vậy, updateCustomer chấp nhận object chỉ có email mà không báo lỗi thiếu field.
Lưu ý:
Partialchỉ làm optional ở cấp 1 (shallow). NếuCustomercó field dạng nested object, field con bên trong vẫn required. Muốn deep partial, bạn phải tự viết type đệ quy hoặc dùng thư viện nhưtype-fest.
3. Pick<T, K>
Pick<T, K> chọn ra một tập con các field từ T theo danh sách key K. Dùng khi bạn chỉ cần một phần dữ liệu của type lớn, ví dụ DTO trả về cho client.
interface Order {
id: string;
customerId: number;
totalAmount: number;
items: string[];
createdAt: Date;
internalNote: string;
}
type OrderSummary = Pick<Order, "id" | "totalAmount" | "createdAt">;
const summary: OrderSummary = {
id: "ORD-001",
totalAmount: 250_000,
createdAt: new Date(),
};
OrderSummary chỉ còn 3 field. Nếu bạn gán thêm field items hay internalNote vào summary, compiler sẽ báo lỗi Object literal may only specify known properties.
3.1. Pick kết hợp union key
Key truyền vào Pick là union string literal, nên bạn có thể tách ra biến riêng để tái sử dụng:
type OrderPublicFields = "id" | "totalAmount" | "createdAt";
type OrderSummary = Pick<Order, OrderPublicFields>;
type OrderListItem = Pick<Order, OrderPublicFields | "customerId">;
Cách này tránh lặp lại danh sách key ở nhiều nơi khi bạn có nhiều DTO biến thể từ cùng một entity.
4. Omit<T, K>
Omit<T, K> làm ngược lại Pick: loại bỏ các key chỉ định, giữ lại phần còn lại. Thường dùng khi muốn ẩn field nhạy cảm hoặc field nội bộ.
interface User {
id: number;
username: string;
passwordHash: string;
role: string;
}
type PublicUser = Omit<User, "passwordHash">;
function toPublicUser(user: User): PublicUser {
const { passwordHash, ...rest } = user;
return rest;
}
Dòng const { passwordHash, ...rest } = user tách passwordHash ra riêng, rest chỉ còn id, username, role — đúng khớp với type PublicUser.
// Output khi console.log(toPublicUser(user))
{ id: 1, username: "nonla_dev", role: "ADMIN" }
Lưu ý:
Omitđược định nghĩa dựa trênPickvàExclude, hoạt động tốt với object literal type nhưng không loại bỏ overload hay discriminated union đúng cách trong một số trường hợp phức tạp. Với union type (type A = B | C),Omit<A, K>sẽ merge các member lại trước khi omit, có thể làm mất tính discriminant. Khi gặp union phức tạp, cân nhắc viết type thủ công thay vì dùngOmittrực tiếp.
5. Record<K, T>
Record<K, T> tạo một object type với tất cả key thuộc K, mỗi key có giá trị kiểu T. Hữu ích khi mô hình hóa map/dictionary có key cố định.
type PaymentStatus = "PENDING" | "PAID" | "CANCELLED";
interface StatusInfo {
label: string;
color: string;
}
const statusDisplay: Record<PaymentStatus, StatusInfo> = {
PENDING: { label: "Chờ thanh toán", color: "orange" },
PAID: { label: "Đã thanh toán", color: "green" },
CANCELLED: { label: "Đã hủy", color: "gray" },
};
function getStatusLabel(status: PaymentStatus): string {
return statusDisplay[status].label;
}
Vì statusDisplay khai báo kiểu Record<PaymentStatus, StatusInfo>, compiler bắt buộc bạn phải có đủ cả ba key PENDING, PAID, CANCELLED. Nếu bạn quên một key, TypeScript báo lỗi ngay tại thời điểm biên dịch — đây là điểm mạnh so với dùng { [key: string]: StatusInfo } thông thường (index signature không kiểm tra đủ key).
5.1. Record với key động
Khi key không phải union literal mà là string chung chung, Record<string, T> tương đương index signature:
const cache: Record<string, number> = {};
cache["order-1"] = 100;
Trường hợp này không còn kiểm tra “đủ key”, vì string có vô số giá trị. Chỉ dùng Record<string, T> khi bạn thật sự cần dictionary tự do, còn lại nên ưu tiên union literal để tận dụng exhaustive check.
6. Kết hợp nhiều utility type
Các utility type có thể lồng vào nhau để tạo type phức tạp hơn mà không cần viết interface mới.
interface Product {
id: string;
name: string;
price: number;
stock: number;
supplierNote: string;
}
type ProductUpdateRequest = Partial<Omit<Product, "id">>;
function updateProduct(id: string, data: ProductUpdateRequest) {
// gọi repository.update(id, data)
}
updateProduct("P001", { price: 199_000 });
Omit<Product, "id"> loại field id (không cho sửa khóa chính), sau đó Partial làm các field còn lại thành optional. Kết quả: caller chỉ cần truyền field nào muốn đổi, không được phép truyền id.
7. Viết test kiểm chứng hành vi
Vì utility type chỉ tồn tại ở compile-time, cách kiểm chứng thực tế là viết unit test cho hàm sử dụng các type đó, đảm bảo runtime behavior đúng như kỳ vọng. Ví dụ dùng Vitest:
<!-- package.json dependency tương đương (không phải Maven) -->
import { describe, it, expect } from "vitest";
interface User {
id: number;
username: string;
passwordHash: string;
role: string;
}
type PublicUser = Omit<User, "passwordHash">;
function toPublicUser(user: User): PublicUser {
const { passwordHash, ...rest } = user;
return rest;
}
describe("toPublicUser", () => {
it("loại bỏ passwordHash khỏi kết quả", () => {
const user: User = {
id: 1,
username: "nonla_dev",
passwordHash: "hash-123",
role: "ADMIN",
};
const result = toPublicUser(user);
expect(result).toEqual({
id: 1,
username: "nonla_dev",
role: "ADMIN",
});
expect(result).not.toHaveProperty("passwordHash");
});
});
Test này xác nhận rằng hàm toPublicUser không chỉ đúng về type mà còn đúng về runtime — passwordHash thực sự biến mất khỏi object trả về, không chỉ bị “ẩn” ở tầng type.
8. Lỗi hay gặp và khi nào không nên dùng
- Nhầm
Pick/Omitlà xóa dữ liệu thật: cả hai chỉ thay đổi type, không ảnh hưởng object runtime. Destructuring (const { x, ...rest } = obj) mới thực sự loại bỏ field khỏi dữ liệu. - Lạm dụng
Partialcho object trả về từ API: nếu response thực sự luôn đủ field, dùngPartialsẽ buộc code gọi phải checkundefinedkhông cần thiết ở khắp nơi. Chỉ dùngPartialcho input (update/patch), không nên dùng cho output đã chắc chắn đầy đủ. Omitvới union type phức tạp: như đã nói ở mục 4,Omitcó thể làm mất discriminated union. Nếu type của bạn là union nhiều biến thể (ví dụShape = Circle | Square), hãy tự viết type hoặc dùng kỹ thuật mapped type có điều kiện thay vìOmittrực tiếp.Record<string, T>thay cho interface rõ ràng: nếu bạn biết trước toàn bộ key, hãy khai báo union literal và dùngRecord<Key, T>để có exhaustive check. DùngRecord<string, T>tùy tiện sẽ mất khả năng phát hiện lỗi thiếu/thừa key lúc biên dịch.
Lưu ý: Tất cả utility type này chỉ hoạt động ở compile-time. Sau khi build ra JavaScript, chúng biến mất hoàn toàn — không có runtime overhead, nhưng cũng không có runtime validation. Nếu dữ liệu đến từ bên ngoài (API, file JSON), bạn vẫn cần validate bằng thư viện như Zod hoặc io-ts.
9. Kết luận
Partial, Pick, Omit và Record giúp bạn tái sử dụng type thay vì viết lại interface trùng lặp. Partial phù hợp cho input dạng update, Pick/Omit dùng để tạo DTO từ entity lớn, còn Record lý tưởng cho dictionary với key cố định, tận dụng exhaustive check của compiler. Hiểu rõ giới hạn của từng loại — đặc biệt là shallow behavior của Partial và vấn đề union type của Omit — sẽ giúp bạn tránh những lỗi type khó debug về sau.