Bài 12.1: Function types và overloading trong TypeScript

Chào mừng trở lại với series Lập trình Web Front-end của FullhouseDev! Sau khi đã làm quen với các kiểu dữ liệu cơ bản, mảng, tuple và enum, hôm nay chúng ta sẽ đi sâu vào hai khái niệm cực kỳ quan trọng trong TypeScript giúp bạn viết các hàm mạnh mẽ, dễ đọcan toàn kiểu dữ liệu hơn: Function TypesFunction Overloading. Nắm vững hai kỹ thuật này sẽ nâng tầm code TypeScript của bạn lên một bậc đáng kể!


Định nghĩa Kiểu dữ liệu cho Hàm (Function Types)

Trong JavaScript thuần túy, hàm là "first-class citizens" - chúng ta có thể gán hàm cho biến, truyền hàm làm đối số cho hàm khác (callback), hoặc trả về hàm từ một hàm khác. TypeScript mang đến khả năng định nghĩa kiểu cho các hàm này, giúp chúng ta mô tả rõ ràng chữ ký của hàm.

Chữ ký của một hàm (function signature) bao gồm:

  1. Các tham số mà hàm nhận vào (tên và kiểu dữ liệu).
  2. Kiểu dữ liệu mà hàm trả về.

Bằng cách định nghĩa kiểu hàm, TypeScript có thể kiểm tra xem liệu bạn có đang sử dụng hàm đúng cách hay không, hoặc liệu một hàm có đáp ứng được "hợp đồng" (contract) mà kiểu dữ liệu yêu cầu hay không.

Cú pháp cơ bản để định nghĩa một kiểu hàm trông giống như một hàm mũi tên (arrow function), nhưng không có thân hàm:

(tham_số_1: Kiểu1, tham_số_2: Kiểu2, ...) => KiểuTrảVề

Hãy xem một số ví dụ:

// Ví dụ 1: Định nghĩa một kiểu hàm đơn giản nhận hai số và trả về một số
type BinaryNumberOperation = (x: number, y: number) => number;

// Khai báo một biến có kiểu BinaryNumberOperation
let addNumbers: BinaryNumberOperation;

// Gán một hàm phù hợp với kiểu đó cho biến
addNumbers = function (a, b) {
    return a + b;
};

console.log(addNumbers(10, 20)); // Output: 30 (Ok, khớp kiểu)

// Nếu cố gắng gán một hàm không khớp, TypeScript sẽ báo lỗi
// addNumbers = function (a: string, b: string): string { // Lỗi compile-time!
//     return a + b;
// };

Giải thích: Ở đây, chúng ta sử dụng type alias để tạo ra một kiểu mới tên là BinaryNumberOperation. Kiểu này mô tả bất kỳ hàm nào nhận hai tham số kiểu number và trả về một giá trị kiểu number. Biến addNumbers được khai báo với kiểu này, và TypeScript đảm bảo rằng chỉ những hàm nào có chữ ký phù hợp mới có thể được gán cho nó. Điều này giúp ngăn chặn các lỗi logic tiềm ẩn do truyền sai kiểu dữ liệu.

Bạn cũng có thể sử dụng kiểu hàm trực tiếp khi khai báo biến hoặc trong các khai báo interface:

// Ví dụ 2: Sử dụng kiểu hàm trực tiếp
let multiplyNumbers: (base: number, factor: number) => number = function (b, f) {
    return b * f;
};

console.log(multiplyNumbers(7, 8)); // Output: 56

// Ví dụ 3: Kiểu hàm cho callback
type LoggerCallback = (message: string) => void; // Hàm nhận string, không trả về gì

function processAsyncOperation(callback: LoggerCallback, data: any) {
    console.log(`Processing data: ${data}`);
    // Giả lập một thao tác bất đồng bộ
    setTimeout(() => {
        const result = `Processed: ${data}`;
        callback(result); // Gọi callback với kết quả (phải khớp kiểu LoggerCallback)
    }, 1000);
}

// Sử dụng hàm processAsyncOperation với một callback function
processAsyncOperation((logMsg) => {
    console.log(`Callback received: ${logMsg}`);
}, "important file");

// Nếu callback không khớp kiểu:
// processAsyncOperation((logMsg: number) => { console.log(logMsg); }, "important file"); // Lỗi!

Giải thích: Ví dụ 2 cho thấy bạn có thể định nghĩa kiểu hàm ngay tại chỗ. Ví dụ 3 là trường hợp rất phổ biến: định nghĩa kiểu cho các hàm callback. Kiểu LoggerCallback đảm bảo rằng bất kỳ hàm nào được truyền vào processAsyncOperation dưới tên callback phải nhận một tham số kiểu stringkhông trả về giá trị nào (void). Điều này làm cho code của bạn rõ ràng hơn về mong đợi đối với callback và an toàn hơn.


Tăng tính Linh hoạt với Function Overloading

Đôi khi, bạn muốn một hàm thực hiện các tác vụ tương tự nhau nhưng xử lý các loại dữ liệu khác nhau hoặc nhận số lượng tham số khác nhau. Ví dụ, hàm add có thể cộng hai số hoặc nối hai chuỗi. Trong JavaScript thuần túy, bạn thường phải kiểm tra kiểu dữ liệu bên trong hàm bằng typeof. TypeScript cung cấp Function Overloading (Nạp chồng hàm) để xử lý tình huống này một cách rõ ràng và an toàn kiểu dữ liệu hơn.

Function Overloading cho phép bạn định nghĩa nhiều chữ ký khác nhau cho cùng một tên hàm. Khi bạn gọi hàm, TypeScript sẽ dựa vào kiểu và số lượng đối số bạn truyền vào để xác định bạn đang "gọi" chữ ký nào trong số các chữ ký đã được định nghĩa.

Cú pháp cho function overloading trong TypeScript bao gồm hai phần:

  1. Các chữ ký khai báo (Overload Signatures): Đây là các dòng khai báo hàm không có thân hàm, chỉ có tên hàm, tham số (với kiểu) và kiểu trả về. Chúng mô tả các cách khác nhau mà hàm có thể được gọi.
  2. Chữ ký triển khai (Implementation Signature): Đây là dòng khai báo hàm có thân hàm chứa logic thực tế. Chữ ký này phải tương thích với tất cả các chữ ký khai báo (thường bằng cách sử dụng union types cho tham số và trả về, hoặc làm cho các tham số tùy chọn).

Quan trọng: TypeScript chỉ sử dụng các chữ ký khai báo khi kiểm tra tính an toàn kiểu dữ liệu lúc bạn gọi hàm. Nó chỉ sử dụng chữ ký triển khai để thực thi logic của hàm. Bạn không thể gọi hàm với chữ ký triển khai nếu nó không khớp với bất kỳ chữ ký khai báo nào.

Hãy xem xét ví dụ về hàm add có thể cộng số hoặc nối chuỗi:

// Ví dụ 1: Overloading với các kiểu tham số khác nhau

// Chữ ký khai báo 1: Cộng hai số
function add(a: number, b: number): number;
// Chữ ký khai báo 2: Nối hai chuỗi
function add(a: string, b: string): string;
// Chữ ký khai báo 3: Nối số và chuỗi
function add(a: number, b: string): string;
// Chữ ký khai báo 4: Nối chuỗi và số
function add(a: string, b: number): string;


// Chữ ký triển khai (Implementation Signature)
// Phải tương thích với tất cả các chữ ký khai báo ở trên.
// Ở đây dùng 'any' hoặc có thể dùng 'number | string'
function add(a: any, b: any): any {
    // Logic thực tế của hàm
    if (typeof a === 'string' || typeof b === 'string') {
        return String(a) + String(b); // Ép kiểu sang string và nối
    }
    return a + b; // Trường hợp còn lại (cộng số)
}

// Cách sử dụng (TypeScript kiểm tra dựa trên Overload Signatures):
console.log(add(1, 2));           // Output: 3     (TypeScript nhận diện chữ ký 1)
console.log(add("Hello ", "World")); // Output: "Hello World" (TypeScript nhận diện chữ ký 2)
console.log(add(5, " apples"));  // Output: "5 apples" (TypeScript nhận diện chữ ký 3)
console.log(add("Oranges ", 10)); // Output: "Oranges 10" (TypeScript nhận diện chữ ký 4)

// Nếu gọi hàm với kiểu không có trong bất kỳ chữ ký khai báo nào:
// console.log(add(true, false)); // Lỗi compile-time! Argument of type 'boolean' is not assignable...

Giải thích: Chúng ta khai báo 4 chữ ký khác nhau cho hàm add. Khi gọi add(1, 2), TypeScript xem xét các chữ ký khai báo và thấy function add(a: number, b: number): number; khớp. Nó đảm bảo kiểu trả về sẽ là number. Tương tự với các cuộc gọi khác. Chữ ký triển khai function add(a: any, b: any): any là cách chúng ta viết code để xử lý tất cả các trường hợp đã khai báo. Lưu ý rằng logic bên trong thân hàm add phải tự kiểm tra kiểu hoặc thực hiện các thao tác cần thiết dựa trên các tham số đầu vào ab vì kiểu any ở đây chỉ là để làm cho chữ ký triển khai tương thích với các overload.

Một ví dụ phổ biến khác là overloading để xử lý số lượng tham số khác nhau:

// Ví dụ 2: Overloading với số lượng tham số khác nhau

// Chữ ký khai báo 1: Tạo ngày từ timestamp (số millisecond)
function makeDate(timestamp: number): Date;
// Chữ ký khai báo 2: Tạo ngày từ năm, tháng, ngày
function makeDate(year: number, month: number, day: number): Date;
// Chữ ký khai báo 3: Tạo ngày từ năm, tháng, ngày, giờ, phút, giây
function makeDate(year: number, month: number, day: number, hour: number, minute: number, second: number): Date;

// Chữ ký triển khai (Implementation Signature)
// Phải bao gồm tất cả các tham số có thể có từ các chữ ký khai báo
function makeDate(mOrTimestamp: number, d?: number, y?: number, h?: number, mi?: number, s?: number): Date {
    // Logic thực tế dựa trên số lượng và kiểu tham số được truyền vào
    if (d !== undefined && y !== undefined) {
        // Nếu có ít nhất 3 tham số (year, month, day)
        // Tháng trong JS Date là 0-indexed, nên d ở đây thực chất là tháng
        // và y thực chất là ngày... cẩn thận với tên biến trong implementation
        // Để rõ ràng, chúng ta có thể đổi tên:
        const year = mOrTimestamp; // Tham số đầu tiên là năm
        const month = d; // Tham số thứ hai là tháng
        const day = y; // Tham số thứ ba là ngày

        if (h !== undefined && mi !== undefined && s !== undefined) {
            // Có đủ 6 tham số
            return new Date(year, month, day, h, mi, s);
        } else {
            // Chỉ có 3 tham số
            return new Date(year, month, day);
        }
    } else {
        // Nếu chỉ có 1 tham số (timestamp)
        const timestamp = mOrTimestamp; // Tham số đầu tiên là timestamp
        return new Date(timestamp);
    }
}

// Cách sử dụng:
console.log(makeDate(1678886400000));      // Output: Date object từ timestamp
console.log(makeDate(2023, 3, 15));       // Output: Date object cho 2023-04-15 (Tháng 3 là tháng 4)
console.log(makeDate(2023, 3, 15, 10, 30, 0)); // Output: Date object cho 2023-04-15 10:30:00

// Nếu gọi hàm với số lượng tham số không khớp bất kỳ chữ ký nào:
// console.log(makeDate(2023)); // Lỗi compile-time! No overload expects 1 arguments, but 1 was given...
// console.log(makeDate(2023, 3)); // Lỗi compile-time! No overload expects 2 arguments...

Giải thích: Hàm makeDate được định nghĩa với ba chữ ký khác nhau, cho phép gọi nó với 1, 3 hoặc 6 tham số. Chữ ký triển khai function makeDate(mOrTimestamp: number, d?: number, y?: number, h?: number, mi?: number, s?: number): Date sử dụng các tham số tùy chọn (?) để có thể nhận số lượng đối số khác nhau. Bên trong thân hàm, chúng ta kiểm tra xem các tham số tùy chọn có tồn tại hay không để xác định chữ ký nào đang được sử dụng và thực hiện logic tương ứng (gọi constructor new Date phù hợp).

Việc sử dụng overloading giúp code của bạn thể hiện rõ ràng hơn các cách thức gọi hàm được hỗ trợ và cho phép TypeScript kiểm tra lỗi ngay tại thời điểm biên dịch, thay vì chờ đến lúc chạy.

Comments

There are no comments at the moment.