TypeScript đã trở thành một công cụ không thể thiếu cho các nhà phát triển web hiện đại, dù bạn theo đuổi Frontend, Backend hay Fullstack. Nó giúp giảm thiểu đáng kể các lỗi phát sinh từ JavaScript động, nhưng đối với người mới, TypeScript có thể khá phức tạp. Nắm vững những kỹ thuật nâng cao sau đây sẽ là bước đệm vững chắc giúp bạn chuyển mình từ một người mới làm quen trở thành một lập trình viên TypeScript thực thụ.
Mục lục
1. Suy Luận Kiểu (Type Inference)
Trái ngược với suy nghĩ của nhiều người mới bắt đầu, bạn không cần phải khai báo kiểu dữ liệu cho mọi thứ một cách tường minh. TypeScript đủ thông minh để suy luận kiểu dữ liệu nếu bạn cung cấp đủ ngữ cảnh.
// Các trường hợp cơ bản
const a = false; // tự động suy luận là boolean
const b = true; // tự động suy luận là boolean
const c = a || b; // tự động suy luận là boolean
// Các trường hợp nâng cao trong khối mã cụ thể
enum CounterActionType {
Increment = "INCREMENT",
IncrementBy = "INCREMENT_BY",
}
interface IncrementAction {
type: CounterActionType.Increment;
}
interface IncrementByAction {
type: CounterActionType.IncrementBy;
payload: number;
}
type CounterAction = IncrementAction | IncrementByAction;
function reducer(state: number, action: CounterAction) {
switch (action.type) {
case CounterActionType.Increment:
// TS suy luận rằng action là IncrementAction
// và không có thuộc tính payload trong khối code này
return state + 1;
case CounterActionType.IncrementBy:
// TS suy luận rằng action là IncrementByAction
// và thuộc tính payload có kiểu number trong khối code này
return state + action.payload;
default:
return state;
}
}
Hiểu và tận dụng suy luận kiểu giúp mã của bạn gọn gàng hơn mà vẫn đảm bảo an toàn về kiểu dữ liệu.
2. Kiểu Literal (Literal Types)
Khi bạn cần một biến chỉ chấp nhận một tập hợp các giá trị cụ thể, kiểu literal sẽ rất hữu ích. Giả sử bạn đang xây dựng một thư viện thông báo trạng thái quyền truy cập:
type PermissionStatus = "granted" | "denied" | "undetermined";
const permissionStatus1: PermissionStatus = "granted"; // ✅ Hợp lệ
// const permissionStatus2: PermissionStatus = "random"; // ❌ Lỗi: "random" không thuộc kiểu PermissionStatus
Kiểu literal không chỉ giới hạn ở chuỗi (string), mà còn hoạt động với số (number) và boolean.
interface UnauthenticatedUser {
isAuthenticated: false; // literal type boolean
}
interface AuthenticatedUser {
data: {
/* ... */
};
isAuthenticated: true; // literal type boolean
}
type User = UnauthenticatedUser | AuthenticatedUser;
const user: User = {
isAuthenticated: false,
}; // ✅ Hợp lệ vì khớp với UnauthenticatedUser
Lưu ý: Các ví dụ trên thường kết hợp kiểu literal với Union Types để tạo ra các kiểu phức tạp hơn, nhưng bạn cũng có thể sử dụng kiểu literal độc lập như một bí danh kiểu (type alias).
3. Enum
Theo tài liệu chính thức của TypeScript:
Enum là một trong số ít tính năng mà TypeScript có không phải là phần mở rộng cấp độ kiểu của JavaScript.
Enum cho phép nhà phát triển định nghĩa một tập hợp các hằng số được đặt tên. Sử dụng enum có thể giúp dễ dàng hơn trong việc ghi lại mục đích hoặc tạo ra một tập hợp các trường hợp phân biệt. TypeScript cung cấp cả enum dựa trên số và chuỗi.
Bạn có thể định nghĩa enum như sau:
enum PrivacyStatus {
Public,
Private,
}
Theo mặc định, nếu không được chỉ định, các giá trị enum dựa trên số sẽ bắt đầu từ 0. Tuy nhiên, để tránh các vấn đề tiềm ẩn khi thêm hoặc bớt các phần tử, đặc biệt khi lưu trữ trong cơ sở dữ liệu, rất nên định nghĩa giá trị tường minh cho từng phần tử, đặc biệt là sử dụng enum chuỗi.
enum PrivacyStatus {
Public = "public",
Private = "private",
}
// Enum dựa trên số với giá trị mặc định
enum DefaultPrivacyStatus {
Public, // 0
Private // 1
}
// Lưu ý: Nếu thêm phần tử mới vào enum số mặc định, giá trị số có thể thay đổi
enum ChangedPrivacyStatus {
Public, // 0
OnlyWith, // 1
Private // 2 - giá trị thay đổi
}
Sử dụng enum giúp mã dễ đọc và bảo trì hơn, đồng thời cung cấp một tập hợp giá trị an toàn về kiểu.
4. Type Guards (Bộ Giữ Kiểu)
Type guards là một cách để thu hẹp kiểu dữ liệu của một biến trong thời gian chạy. Chúng là các hàm giúp bạn kiểm tra xem một giá trị thuộc kiểu nào.
// Tiếp tục từ ví dụ User ở trên
interface UnauthenticatedUser {
isAuthenticated: false;
}
interface AuthenticatedUser {
data: {
userId: string;
username: string;
};
isAuthenticated: true;
}
type User = UnauthenticatedUser | AuthenticatedUser;
// Custom Type Guard
const isAuthenticatedUser = (user: User): user is AuthenticatedUser => {
return user.isAuthenticated === true;
};
const currentUser: User = { isAuthenticated: true, data: { userId: '123', username: 'john_doe' } };
if (isAuthenticatedUser(currentUser)) {
// Bên trong khối if này, TypeScript biết currentUser là AuthenticatedUser
console.log("User data:", currentUser.data.username); // ✅ Có thể truy cập .data
} else {
// Bên trong khối else, TypeScript biết currentUser là UnauthenticatedUser
// console.log(currentUser.data); // ❌ Lỗi: data không tồn tại trên UnauthenticatedUser
}
Bạn có thể định nghĩa các type guard tùy chỉnh theo yêu cầu của mình. TypeScript cũng cung cấp các type guard tích hợp sẵn như `typeof` và `instanceof`.
5. Index Signatures và Record
Khi bạn cần định nghĩa kiểu cho một đối tượng mà các khóa (keys) của nó có tính động và kiểu giá trị (values) là nhất quán, bạn có thể sử dụng Index Signature hoặc utility type `Record`.
enum ParticipationStatus {
Joined = "JOINED",
Left = "LEFT",
Pending = "PENDING",
}
// Sử dụng Index Signature
interface ParticipantDataByIndexSignature {
[id: string]: ParticipationStatus; // Khóa là string, giá trị là ParticipationStatus
}
// Sử dụng Record utility type
type ParticipantDataByRecord = Record<string, ParticipationStatus>; // Record<Kiểu_Khóa, Kiểu_Giá_Trị>
const participants: ParticipantDataByIndexSignature = {
id1: ParticipationStatus.Joined,
user2: ParticipationStatus.Left,
abc3: ParticipationStatus.Pending,
// Các khóa khác cũng hợp lệ miễn là chúng là chuỗi
};
const participants2: ParticipantDataByRecord = {
room1: ParticipationStatus.Joined,
room2: ParticipationStatus.Left,
};
// const invalidParticipants: ParticipantDataByIndexSignature = {
// id1: "Joining", // ❌ Lỗi: Giá trị phải thuộc ParticipationStatus
// };
Cả hai cách đều định nghĩa một đối tượng có các khóa thuộc kiểu `string` và các giá trị thuộc kiểu `ParticipationStatus`. `Record` thường được ưa chuộng hơn vì nó là một utility type chuẩn và có thể kết hợp dễ dàng với các utility type khác.
6. Generics
Thỉnh thoảng, bạn sẽ muốn tạo ra một hàm, lớp, hoặc kiểu có thể hoạt động với nhiều kiểu dữ liệu khác nhau mà vẫn duy trì tính an toàn về kiểu. Generics cho phép bạn làm điều đó bằng cách sử dụng các biến kiểu trong dấu ngoặc nhọn `<>`.
// Hàm generic nhận vào dữ liệu bất kỳ và trả về chuỗi JSON
const getJsonString = <T>(data: T): string => JSON.stringify(data, null, 2);
// Sử dụng
const jsonData1 = getJsonString({ name: "John Doe", age: 30 }); // T được suy luận là { name: string, age: number }
const jsonData2 = getJsonString([1, 2, 3]); // T được suy luận là number[]
const jsonData3 = getJsonString("Hello World"); // T được suy luận là string
console.log(jsonData1);
Generics cũng cho phép bạn định nghĩa các ràng buộc (constraints) về kiểu dữ liệu mà bạn muốn làm việc. Ví dụ, một hàm chỉ làm việc với các đối tượng có thuộc tính `id` kiểu `string`.
// Hàm generic chỉ làm việc với mảng các đối tượng có id kiểu string
const removeItemFromArray = <T extends { id: string }>( // T phải mở rộng kiểu { id: string }
array: T[],
id: string
): T[] => {
const index = array.findIndex((item) => item.id === id);
if (index !== -1) {
array.splice(index, 1);
}
return array;
};
// Sử dụng hợp lệ
const users = [
{ id: "1", name: "John Doe" },
{ id: "2", name: "Jane Doe" },
];
const updatedUsers = removeItemFromArray(users, "1"); // ✅ Hợp lệ
console.log(updatedUsers); // [{ id: "2", name: "Jane Doe" }]
// Sử dụng không hợp lệ
// const numbers = [1, 2, 3];
// removeItemFromArray(numbers, "1"); // ❌ Lỗi: number[] không đáp ứng ràng buộc { id: string }
Generics là một công cụ mạnh mẽ để viết mã tái sử dụng, linh hoạt và an toàn về kiểu.
7. Kiểu Bất Biến (Immutable Types)
Kiểu bất biến đảm bảo rằng dữ liệu trong đối tượng hoặc mảng của bạn không thể bị thay đổi sau khi được tạo. Điều này giúp ngăn chặn các tác dụng phụ không mong muốn, làm cho mã của bạn dễ dự đoán và gỡ lỗi hơn.
Bạn có thể sử dụng `Readonly` và `ReadonlyArray` để áp đặt tính bất biến.
Sử dụng Readonly
cho đối tượng
interface UserProfile {
name: string;
age: number;
}
const user: Readonly<UserProfile> = { // user không thể bị thay đổi
name: "Alice",
age: 25,
};
// user.name = "Bob"; // ❌ Lỗi: Cannot assign to 'name' because it is a read-only property
// user.age = 26; // ❌ Lỗi tương tự
Sử dụng ReadonlyArray
cho mảng
const primeNumbers: ReadonlyArray<number> = [2, 3, 5, 7]; // primeNumbers không thể bị thay đổi
// primeNumbers.push(11); // ❌ Lỗi: Property 'push' does not exist on type 'readonly number[]'
// primeNumbers[0] = 1; // ❌ Lỗi: Index signature in type 'readonly number[]' only permits reading
Bất biến sâu (Deep Immutability)
Nếu bạn cần bất biến sâu (đảm bảo cả các đối tượng lồng nhau cũng không thể thay đổi), bạn có thể tự định nghĩa kiểu utility hoặc sử dụng các thư viện có sẵn.
// Kiểu utility DeepReadonly (ví dụ đơn giản)
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
interface Product {
id: string;
details: {
name: string;
price: number;
};
}
const product: DeepReadonly<Product> = {
id: "prod-1",
details: {
name: "Laptop",
price: 1200,
},
};
// product.id = "prod-2"; // ❌ Lỗi
// product.details.name = "Desktop"; // ❌ Lỗi: Không thể gán cho thuộc tính 'name' vì nó là thuộc tính chỉ đọc
// product.details.price = 1100; // ❌ Lỗi
Áp dụng nguyên tắc bất biến (dù là nông hay sâu) giúp cải thiện đáng kể tính dễ hiểu và khả năng bảo trì của mã.
8. Utility Types
TypeScript cung cấp một bộ Utility Types sẵn có để tạo ra các kiểu mới dựa trên các kiểu hiện có. Các utility type này cực kỳ hữu ích khi bạn cần tạo một kiểu mới tương tự như một kiểu đã có nhưng có những thay đổi nhỏ.
Các utility type phổ biến bao gồm:
Pick<Type, Keys>
: Chọn các thuộc tính cần thiết từ một kiểu đối tượng.Omit<Type, Keys>
: Lấy tất cả thuộc tính từ một kiểu đối tượng, bỏ qua các khóa đã chọn.Partial<Type>
: Biến tất cả các thuộc tính của một kiểu đối tượng thành tùy chọn (optional).Required<Type>
: Biến tất cả các thuộc tính tùy chọn của một kiểu đối tượng thành bắt buộc (required).
interface FullUser {
id: string;
name: string;
age?: number; // Thuộc tính tùy chọn
email: string;
createdAt: Date;
}
// Ví dụ sử dụng Utility Types:
type UserSummary = Pick<FullUser, "id" | "name" | "email">;
// UserSummary sẽ có các thuộc tính id, name, email
type UserWithoutTimestamps = Omit<FullUser, "createdAt">;
// UserWithoutTimestamps sẽ có các thuộc tính id, name, age?, email
type PartialUserData = Partial<FullUser>;
// PartialUserData sẽ có tất cả thuộc tính của FullUser nhưng đều là tùy chọn (?):
// id?, name?, age?, email?, createdAt?
type RequiredUserData = Required<FullUser>;
// RequiredUserData sẽ có tất cả thuộc tính của FullUser và đều là bắt buộc:
// id, name, age, email, createdAt
// Lưu ý: age từ tùy chọn (?) trở thành bắt buộc
// Ví dụ về kiểu kết quả:
/*
type UserSummary = {
id: string;
name: string;
email: string;
}
type UserWithoutTimestamps = {
id: string;
name: string;
age?: number;
email: string;
}
type PartialUserData = {
id?: string;
name?: string;
age?: number;
email?: string;
createdAt?: Date;
}
type RequiredUserData = {
id: string;
name: string;
age: number;
email: string;
createdAt: Date;
}
*/
Sử dụng utility types giúp bạn thao tác và tái sử dụng các định nghĩa kiểu một cách hiệu quả, tránh lặp lại mã.
9. Union & Intersection Types
Như đã đề cập trước đó trong phần Kiểu Literal, chúng ta có thể kết hợp 2 hoặc nhiều kiểu dữ liệu bằng cách sử dụng Union Types. Union Types được định nghĩa bằng toán tử `|` và kết hợp nhiều kiểu thành một kiểu duy nhất. Điều này hữu ích khi một biến có thể nhận một trong nhiều kiểu khác nhau.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
// Union Type: Một biến Shape có thể là Circle, Square, hoặc Triangle
type Shape = Circle | Square | Triangle;
const myShape: Shape = { kind: "circle", radius: 10 }; // Hợp lệ
// const anotherShape: Shape = { kind: "rectangle", width: 5 }; // ❌ Lỗi: kind "rectangle" không tồn tại trong Union
Toán tử Intersection Types sử dụng toán tử `&` và kết hợp các kiểu đối tượng lại với nhau để tạo ra một kiểu mới chứa tất cả các thuộc tính từ các kiểu ban đầu.
interface BackendResponse {
statusCode: number;
message: string;
}
interface UserPayload {
userId: string;
username: string;
}
// Intersection Type: UserApiResponse có tất cả thuộc tính của cả BackendResponse và UserPayload
type UserApiResponse = BackendResponse & UserPayload;
const apiResult: UserApiResponse = {
statusCode: 200,
message: "Success",
userId: "user-abc",
username: "coder123",
}; // Phải có đủ tất cả các thuộc tính từ cả hai interface
// const invalidApiResult: UserApiResponse = { // ❌ Lỗi: thiếu userId và username
// statusCode: 400,
// message: "Bad Request",
// };
Hiểu rõ sự khác biệt và cách sử dụng giữa Union (OR) và Intersection (AND) giúp bạn mô hình hóa dữ liệu và các mối quan hệ kiểu phức tạp một cách chính xác.
Lời Kết
Nắm vững những kỹ thuật này sẽ giúp bạn tự tin hơn rất nhiều khi làm việc với TypeScript và codebase của mình. Việc áp dụng chúng không chỉ cải thiện chất lượng mã mà còn tăng hiệu quả làm việc của bạn như một nhà phát triển chuyên nghiệp.
Đừng ngần ngại thử nghiệm và tích hợp chúng vào các dự án của bạn. Chúc bạn thành công trên hành trình làm chủ TypeScript!