Bài 11.4: Optional và Readonly properties trong TypeScript

Chào mừng bạn quay trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào hai khái niệm cực kỳ hữu ích trong TypeScript giúp định nghĩa các kiểu dữ liệu một cách linh hoạt và mạnh mẽ hơn: Optional Properties (thuộc tính tùy chọn) và Readonly Properties (thuộc tính chỉ đọc). Nắm vững hai khái niệm này sẽ giúp code của bạn an toàn, dễ đọc và dễ bảo trì hơn rất nhiều.

TypeScript cho phép chúng ta định nghĩa cấu trúc của các đối tượng thông qua interface hoặc type. Tuy nhiên, không phải lúc nào mọi thuộc tính của một đối tượng cũng bắt buộc phải tồn tại, hoặc không phải lúc nào chúng ta cũng muốn cho phép thay đổi giá trị của một thuộc tính sau khi nó đã được gán. Đây là lúc ?readonly tỏa sáng!

Optional Properties: Khi dữ liệu có thể "vắng mặt"

Trong thế giới thực, không phải lúc nào chúng ta cũng có đầy đủ mọi thông tin. Ví dụ, một người dùng có thể có số điện thoại hoặc không, một cấu hình có thể có cài đặt nâng cao hoặc dùng mặc định. TypeScript cho phép chúng ta mô tả điều này bằng cách đánh dấu thuộc tính là tùy chọn sử dụng ký hiệu ? sau tên thuộc tính khi định nghĩa interface hoặc type.

Cú pháp cơ bản

Để làm cho một thuộc tính trở thành tùy chọn, bạn chỉ cần thêm dấu chấm hỏi (?) vào cuối tên thuộc tính trong định nghĩa kiểu:

interface User {
  id: number;
  name: string;
  email?: string; // email là tùy chọn
  phone?: string; // phone cũng là tùy chọn
}

Trong ví dụ trên, idname là các thuộc tính bắt buộc. Một đối tượng kiểu User buộc phải có hai thuộc tính này. Ngược lại, emailphone là các thuộc tính tùy chọn. Một đối tượng User có thể có hoặc không có chúng.

Tại sao Optional Properties hữu ích?
  1. Linh hoạt khi khởi tạo đối tượng: Bạn có thể tạo các đối tượng cùng một kiểu nhưng với số lượng thuộc tính khác nhau tùy thuộc vào dữ liệu có sẵn.
  2. Làm việc với API: Rất nhiều API trả về dữ liệu mà một số trường có thể bị thiếu tùy thuộc vào ngữ cảnh hoặc quyền truy cập. Optional properties giúp mô tả chính xác cấu trúc dữ liệu nhận được.
  3. Đối tượng cấu hình: Khi xây dựng các hàm hoặc đối tượng nhận vào một object cấu hình với nhiều tùy chọn, đánh dấu các tùy chọn là optional giúp người dùng chỉ cần cung cấp những gì họ muốn thay đổi khỏi giá trị mặc định.
Ví dụ minh họa cách sử dụng

Với định nghĩa interface User ở trên, chúng ta có thể tạo các đối tượng như sau:

// Đối tượng hợp lệ mà không có email và phone
const user1: User = {
  id: 1,
  name: "Alice"
};

// Đối tượng hợp lệ chỉ có email
const user2: User = {
  id: 2,
  name: "Bob",
  email: "bob@example.com"
};

// Đối tượng hợp lệ có cả email và phone
const user3: User = {
  id: 3,
  name: "Charlie",
  email: "charlie@example.com",
  phone: "0123456789"
};

// Ví dụ về đối tượng không hợp lệ (thiếu thuộc tính bắt buộc)
// const user4: User = { name: "David" }; // Lỗi TypeScript: Property 'id' is missing in type '{ name: string; }' but required in type 'User'.

Giải thích code: Các ví dụ trên cho thấy tính linh hoạt của optional properties. Chúng ta có thể tạo các đối tượng user1, user2, user3 với các tập hợp thuộc tính tùy chọn khác nhau mà TypeScript vẫn chấp nhận vì chúng tuân thủ định nghĩa User. Việc bỏ qua thuộc tính bắt buộc như id trong user4 sẽ bị TypeScript báo lỗi ngay lập tức, giúp bạn phát hiện sớm các sai sót.

Truy cập thuộc tính tùy chọn

Khi làm việc với một thuộc tính tùy chọn, điều quan trọng cần nhớ là giá trị của nó có thể là undefined nếu thuộc tính đó không tồn tại trên đối tượng. Nếu bạn bật tùy chọn strictNullChecks trong tsconfig.json (rất nên làm để code an toàn hơn!), TypeScript sẽ yêu cầu bạn xử lý trường hợp này.

Cách phổ biến nhất để truy cập thuộc tính tùy chọn một cách an toàn là kiểm tra sự tồn tại của nó trước khi sử dụng, hoặc sử dụng cú pháp Optional Chaining (?.) được giới thiệu từ ECMAScript 2020 và được TypeScript hỗ trợ mạnh mẽ.

function displayUserInfo(user: User) {
  console.log(`ID: ${user.id}`);
  console.log(`Tên: ${user.name}`);

  // Cách 1: Kiểm tra sự tồn tại
  if (user.email) {
    console.log(`Email: ${user.email}`);
  }

  // Cách 2: Sử dụng Optional Chaining (?)
  // Nếu user.phone là undefined, toàn bộ biểu thức user.phone?.substring(...)
  // sẽ trả về undefined thay vì gây lỗi runtime
  console.log(`Số điện thoại (dạng ngắn): ${user.phone?.substring(0, 3)}...`);

  // Kết hợp Optional Chaining với Nullish Coalescing (??)
  // Sử dụng giá trị mặc định nếu thuộc tính là null hoặc undefined
  const displayedEmail = user.email ?? "Không có Email";
  console.log(`Email (hoặc mặc định): ${displayedEmail}`);
}

displayUserInfo(user1);
// Output:
// ID: 1
// Tên: Alice
// Số điện thoại (dạng ngắn): undefined...
// Email (hoặc mặc định): Không có Email

displayUserInfo(user2);
// Output:
// ID: 2
// Tên: Bob
// Email: bob@example.com
// Số điện thoại (dạng ngắn): undefined...
// Email (hoặc mặc định): bob@example.com

Giải thích code: Hàm displayUserInfo minh họa cách xử lý thuộc tính tùy chọn. Chúng ta không thể đơn giản truy cập user.email.toUpperCase() nếu không kiểm tra vì user.email có thể là undefined và gọi phương thức trên undefined sẽ gây lỗi. Bằng cách sử dụng if (user.email) hoặc user.phone?.substring(...), chúng ta đảm bảo code chạy an toàn ngay cả khi thuộc tính không tồn tại. Toán tử ?? cung cấp một cách ngắn gọn để cung cấp giá trị mặc định.

Readonly Properties: Bảo vệ dữ liệu "bất biến"

Ngược lại với tùy chọn, đôi khi chúng ta muốn đảm bảo rằng một khi một thuộc tính được gán giá trị, nó sẽ không bao giờ thay đổi nữa. Đây là lúc từ khóa readonly phát huy tác dụng. Khi bạn đánh dấu một thuộc tính là readonly, nó chỉ có thể được gán giá trị trong quá trình khởi tạo đối tượng hoặc bên trong constructor của một class.

Cú pháp cơ bản

Để làm cho một thuộc tính chỉ đọc, bạn thêm từ khóa readonly trước tên thuộc tính trong định nghĩa kiểu:

interface Point {
  readonly x: number; // x là thuộc tính chỉ đọc
  readonly y: number; // y cũng là thuộc tính chỉ đọc
}

Trong định nghĩa Point này, xy là các thuộc tính bắt buộc phải có giá trị khi đối tượng được tạo, và giá trị đó không thể bị thay đổi sau đó.

Tại sao Readonly Properties hữu ích?
  1. Tính bất biến (Immutability): Tạo ra các đối tượng mà state của chúng không thể thay đổi sau khi được tạo. Điều này làm cho code dễ dự đoán hơn, giảm thiểu lỗi do side effect không mong muốn, và rất quan trọng trong các kiến trúc quản lý state phức tạp (như Redux).
  2. An toàn dữ liệu: Bảo vệ các dữ liệu quan trọng (ví dụ: ID, cấu hình ban đầu) khỏi bị thay đổi vô tình ở những phần khác của ứng dụng.
  3. Làm rõ ý định: Việc đánh dấu một thuộc tính là readonly truyền tải rõ ràng ý định của lập trình viên rằng thuộc tính này được thiết kế để không thay đổi.
Ví dụ minh họa cách sử dụng

Với định nghĩa interface Point ở trên:

// Khởi tạo đối tượng Point - đây là lúc gán giá trị cho readonly properties
const p1: Point = { x: 10, y: 20 };

console.log(`Tọa độ ban đầu: (${p1.x}, ${p1.y})`); // Output: Tọa độ ban đầu: (10, 20)

// Thử thay đổi giá trị của readonly properties -> Sẽ gây lỗi TypeScript!
// p1.x = 5; // Lỗi: Cannot assign to 'x' because it is a read-only property.
// p1.y = 15; // Lỗi: Cannot assign to 'y' because it is a read-only property.

// Chúng ta vẫn có thể đọc giá trị của readonly properties bình thường
const currentX = p1.x;
console.log(`Giá trị X hiện tại: ${currentX}`); // Output: Giá trị X hiện tại: 10

Giải thích code: Ví dụ này minh họa rõ ràng cách hoạt động của readonly. Chúng ta có thể gán giá trị cho xy chỉ trong quá trình khởi tạo đối tượng p1. Bất kỳ cố gắng nào để gán lại giá trị cho p1.x hoặc p1.y sau đó đều sẽ bị TypeScript chặn lại với thông báo lỗi rõ ràng. Tuy nhiên, việc đọc giá trị của chúng vẫn hoàn toàn bình thường.

Readonly và đối tượng/mảng

Một điểm quan trọng cần lưu ý là readonly chỉ áp dụng cho chính thuộc tính được đánh dấu. Nếu thuộc tính readonly đó là một đối tượng hoặc một mảng, thì chỉ có việc gán lại toàn bộ đối tượng/mảng mới bị cấm. Bạn vẫn có thể thay đổi nội dung bên trong đối tượng hoặc mảng đó (trừ khi nội dung đó cũng được định nghĩa là readonly hoặc bạn sử dụng các kiểu dữ liệu bất biến khác).

interface AppConfig {
  readonly id: string;
  readonly settings: { theme: string, pageSize: number }; // settings là readonly, nhưng object bên trong thì không
  readonly tags: string[]; // tags là readonly, nhưng array bên trong thì không
}

const appConfig: AppConfig = {
  id: "cfg-001",
  settings: { theme: 'dark', pageSize: 10 },
  tags: ['typescript', 'web', 'frontend']
};

// Không thể gán lại toàn bộ thuộc tính readonly
// appConfig.id = "cfg-002"; // Lỗi!
// appConfig.settings = { theme: 'light', pageSize: 20 }; // Lỗi!
// appConfig.tags = ['js', 'css']; // Lỗi!

// NHƯNG, có thể thay đổi nội dung bên trong đối tượng/mảng
appConfig.settings.theme = 'light'; // Hợp lệ! Thay đổi thuộc tính của object bên trong settings
appConfig.tags.push('development'); // Hợp lệ! Thêm phần tử vào array bên trong tags

console.log(appConfig.settings.theme); // Output: light
console.log(appConfig.tags); // Output: ['typescript', 'web', 'frontend', 'development']

Giải thích code: Như bạn thấy, mặc dù settingstags được đánh dấu là readonly, chúng ta vẫn có thể sửa đổi nội dung bên trong chúng (appConfig.settings.theme = 'light'appConfig.tags.push('development')). Điều này là do readonly chỉ ngăn việc gán lại tham chiếu (appConfig.settings = ... hoặc appConfig.tags = ...), chứ không tự động làm cho toàn bộ cấu trúc dữ liệu bên dưới trở nên bất biến một cách sâu sắc (deep immutable). Để đạt được bất biến hoàn toàn, bạn có thể sử dụng các kiểu dữ liệu Readonly<T> hoặc các thư viện chuyên dụng cho immutability như Immer hoặc Immutable.js.

Kết hợp Optional và Readonly

Chúng ta hoàn toàn có thể kết hợp cả hai khái niệm này. Một thuộc tính có thể vừa là tùy chọnchỉ đọc. Điều này có nghĩa là thuộc tính đó có thể có hoặc không tồn tại trên đối tượng. Nếu nó tồn tại, nó phải được gán giá trị khi khởi tạo và giá trị đó sẽ không thể thay đổi sau đó.

interface Product {
  id: number;
  name: string;
  readonly serialNumber?: string; // Thuộc tính tùy chọn VÀ chỉ đọc
  releaseDate?: readonly string; // Thuộc tính tùy chọn, và nếu tồn tại thì là chuỗi chỉ đọc
}

const product1: Product = {
  id: 101,
  name: "Laptop"
  // serialNumber và releaseDate không tồn tại, hợp lệ
};

const product2: Product = {
  id: 102,
  name: "Mouse",
  serialNumber: "SN12345ABC" // Tồn tại và được gán giá trị ban đầu
  // releaseDate không tồn tại
};

const product3: Product = {
  id: 103,
  name: "Keyboard",
  serialNumber: "SN6789XYZ",
  releaseDate: "2023-10-26" // Tồn tại và được gán giá trị ban đầu
};

// Thử thay đổi serialNumber hoặc releaseDate -> Lỗi!
// product2.serialNumber = "NEW-SN"; // Lỗi! 'serialNumber' là readonly
// product3.releaseDate = "2024-01-01"; // Lỗi! 'releaseDate' là readonly

// Có thể kiểm tra và đọc giá trị nếu chúng tồn tại
if (product3.serialNumber) {
  console.log(`Serial của Keyboard: ${product3.serialNumber}`); // Output: Serial của Keyboard: SN6789XYZ
}

Giải thích code: Trong ví dụ Product, serialNumber là tùy chọn và chỉ đọc. Bạn có thể tạo một Product mà không có serialNumber (product1). Nếu bạn cung cấp serialNumber khi tạo (product2, product3), giá trị đó sẽ không thể thay đổi sau đó. Tương tự với releaseDate, nếu nó tồn tại, nó là một chuỗi không thể thay đổi.

Ứng dụng thực tế

  • Optional Properties: Thường dùng trong các trường hợp dữ liệu có thể thiếu, các object chứa tham số tùy chọn cho hàm, hoặc các form object nơi người dùng không bắt buộc phải điền tất cả các trường.
  • Readonly Properties: Lý tưởng cho việc định nghĩa các hằng số dạng object, cấu hình ứng dụng không đổi sau khi khởi tạo, hoặc các đối tượng state trong các thư viện quản lý state để khuyến khích luồng dữ liệu một chiều (unidirectional data flow).

Comments

There are no comments at the moment.