第一週第五天:函式型別與型別斷言

第一週第五天:函式型別與型別斷言

學習資源


函式的型別

在 JavaScript 中,有兩種常見的定義函式的方式——函式宣告(Function Declaration)和函式表達式(Function Expression):

// 函式宣告(Function Declaration)
function add(a, b) {
  return a + b;
}

// 函式表達式(Function Expression)
const addNumbers = function (x, y) {
  return x + y;
};

在 TypeScript 中,函式的型別透過參數型別(輸入)的返回值型別(輸出)來定義,以下會分別說明函式宣告和函式表達式如何定義其函示的型別。

函式宣告

一樣沿用上面的例子,在 TypeScript 中會這樣定義函式宣告的型別:

function add(a: number, b: number): number {
    return a + b;
}

使用函式時需注意,傳遞給函式的引數不能少於或多於函式定義的參數數量:

function add(a: number, b: number): number {
  return a + b;
}

// 🔴 這會報錯,因為只傳入了一個引數,但函式需要兩個引數
add(1);

// 🔴 這也會報錯,因為傳入了三個引數,但函式只需要兩個引數
add(1, 2, 3);

函式表達式

一樣沿用上面的例子,在 TypeScript 中我們第一直覺可能會這樣定義函式表達式的型別:

const addNumbers = function (x: number, y: number): number {
  return x + y;
};

這樣寫雖然不會報錯,但實際上這樣只有定義等號 = 右側的匿名函式 function (x, y) {} 的型別,而左側變數 addNumbers 是 TypeScript 根據右側的匿名函式的型別來自動推導出它的型別。

如果我們想要明確指定變數 addNumbers 的型別,需要在 addNumbers 後方使用冒號 : 來指定它的型別為一個函式:

const addNumbers: (x: number, y: number) => number = function (
  x: number,
  y: number
): number {
  return x + y;
};

在 TypeScript 的型別定義中,箭頭(=>)用於表示函式的定義。箭頭左邊表示函式的參數型別,箭頭右邊表示函式的返回值型別。

注意不要跟 ES6 中表示箭頭函示(Arrow Function)的箭頭(=>)搞混了。

用介面定義函式的型別

也可以使用介面來定義函式的型別,將參數和返回值的型別指定在介面中,如下所示:

// 使用介面定義函式型別
interface AddFunction {
  (a: number, b: number): number;
}

// 使用定義的介面來指定函式的型別
const addNumbers: AddFunction = function (a: number, b: number): number {
  return a + b;
};

console.log(addNumbers(1, 2)); // 輸出 3

可選參數

介面的可選屬性類似,在參數名的後面加上 ? 符號來標記可選參數:

function greet(name: string, age?: number): string {
  if (age) {
    return `Hello, my name is ${name} and I am ${age} years old.`;
  } else {
    return `Hello, my name is ${name}.`;
  }
}

console.log(greet("Alice")); // "Hello, my name is Alice."
console.log(greet("Bob", 30)); // "Hello, my name is Bob and I am 30 years old."

使用可選參數有幾個注意事項:

可選參數必須放在必須參數之後

在函數的參數列表中,可選參數應該放在必需參數之後。這是因為 TypeScript 是根據參數的順序來確定是否傳遞了可選參數。例如:

// 錯誤示範:可選引數 age 放在了必需引數 name 之前
function greet(age?: number, name: string): string {
  // ...
}

設定預設值時要注意可選參數

當我們為可選參數設定了預設值時,無需再使用 ? 標記,因為預設值已經表示了參數是可選的,也不會受到「可選參數應該放在必需參數之後」的限制了,想放哪就放哪。例如:

// 正確示範:age 有預設值,無需使用 '?'
function greet(name: string, age: number = 0): string {
  // ...
}

使用可選參數時要注意未定義值

因為可選參數可能未傳入值且沒有預設值,所以在函數內部使用可選參數時,要注意檢查它是否為 undefined 以免出現非預期的情況。例如:

function greet(name: string, age?: number): string {
  if (age === undefined) {
    // 如果沒有加判斷,未傳入 age 時會返回 I am undefined years old.
    return `Hello, my name is ${name}.`;
  } else {
    return `Hello, my name is ${name} and I am ${age} years old.`;
  }
}

其餘參數

當我們想處理數量不固定的參數時,與 ES6 的其餘參數(Rest Parameter)一樣,將最後一個參數以 ... 開頭,它會將其餘的參數視為一個陣列:

function sum(...numbers: number[]): number {
  return numbers.reduce((acc, curr) => acc + curr, 0);
}

console.log(sum(1, 2)); // 3
console.log(sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)); // 55

函式多載(Overloads)

函式多載的意思是同一個函式名稱定義多個型別。根據傳入的引數類型和數量,TypeScript 編譯器會自動選擇合適的函數型別。可以讓我們為不同的引數組合提供各別的型別檢查,提高程式碼的可讀性和維護性。

讀到這邊可能會想,用聯合型別不是也能定義多個型別嗎?但函式跟一般變數不同處是它會有返回值,當不同型別的輸入有不同的型別的輸出,這時用函式多載來定義會更容易理解。

一樣先上例子:

function process(input: string | number): string[] | number {
  if (typeof input === 'string') {
    return input.split('');
  } else {
    return input * 2;
  }
}

let result1 = process("hello"); // 結果: ["h", "e", "l", "l", "o"]
let result2 = process(10); // 結果: 20

在這個例子中,當傳入的 inputstring 類型的引數,process 函式會回傳 string[] 類型的值;當傳入的 inputnumber 類型的引數,process 函式會回傳 number 類型的值。

但這是當我們把函式的實作內容全看過一遍才會知道的事,只看 function process(input: string | number): string[] | number 不會確定是輸入 string 回傳 string[]number 回傳 number,一但實作的內容更複雜,想用 any 的衝動肯定都湧上來了(誤)。

而函式多載就來解決這個問題的:

// 函式型別一:接受 string 類型的引數,返回 string[]
function process(input: string): string[];
// 函式型別二:接受 number 類型的引數,返回 number
function process(input: number): number;

// 原本的函式
function process(input: string | number): string[] | number {
  if (typeof input === 'string') {
    return input.split('');
  } else {
    return input * 2;
  }
}

let result1 = process("hello"); // 結果: ["h", "e", "l", "l", "o"]
let result2 = process(10); // 結果: 20

對比有無使用函式多載在編譯器的程式碼提示有什麼不同:

沒有使用函式多載

使用函式多載

另外,由於 TypeScript 在決定使用哪個多載定義時,會從最前面的多載定義開始,如果找到一個適合的定義,就會停止尋找。因此當我們有多個多載定義時,要把型別定義範圍較小較精確的放在前面,確保 TypeScript 編譯器選擇正確的定義。

型別斷言(Type Assertion)

型別斷言是 TypeScript 中的一個語法特性,它允許我們指定更準確的型別,提供 TypeScript 編譯器無法自行推斷的額外型別資訊。

例如,我們使用 document.getElementById 來選取頁面上的元素,TypeScript 只能推斷出這會返回某種 HTMLElement,不知道究竟是哪一種,但我們可能知道選中的會是一個 HTMLCanvasElement 類型的元素

在這個情況下,我們就能使用型別斷言來指定更具體的型別,在 TypeScript 中,有兩種寫法來指定型別斷言:

  1. 尖括號 <>(angle-bracket)寫法:

     const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
    
  2. as 寫法:

     const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
    

在 TypeScript 的 TSX 文件中,尖括號 <T> 語法與 JSX 語法的標籤(例如 <div>)有語法衝突。因為尖括號在 JSX 語法中用來表示一個標籤的開始,TypeScript 無法分辨尖括號是用於 JSX 元素還是型別斷言亦或是泛型。為了避免這種衝突,TypeScript 引入了 as 語法作為 TSX 文件中的替代方法。

所以在 TSX 文件中,我們應該使用 as 語法進行型別斷言,而不是尖括號 <T> 語法。

接下來介紹一些需要使用型別斷言的常見情況。

處理任意值 any 型別

當我們在處理一個 any 型別的值時,可能需要將其斷言為更具體的型別。這樣可以避免在後續的程式碼中使用額外的型別檢查和警告。

let value: any = "This is a string.";

// 沒有使用型別斷言
let stringLength1: number = value.length; // 編譯器不會報錯,但無法確保 `value` 真的是字串

// 使用型別斷言
let stringLength2: number = (value as string).length; // 確保編譯器將 `value` 視為字串,並且能夠取得其長度

處理聯合型別

在使用聯合型別時,某些情況下 TypeScript 編譯器可能無法確定目標值的確切型別。在這種情況下,我們可以使用型別斷言來明確指定目標值的型別。

interface AnimalWithName {
  name: string;
}

interface AnimalWithAge {
  age: number;
}

let myAnimal: AnimalWithName | AnimalWithAge = { name: "Dog" };

// 沒有使用型別斷言
let animalName1: string = myAnimal.name; 
// 因為 `myAnimal` 型別有可能是 `AnimalWithAge`,但 `AnimalWithAge` 並沒有 `name` 屬性
// 所以編譯器會報錯:Property 'name' does not exist on type 'AnimalWithName | AnimalWithAge'

// 使用型別斷言
let animalName2: string = (myAnimal as AnimalWithName).name; // 確保編譯器將 `myAnimal` 視為具有 `name` 屬性的物件,並且能夠獲取其值

處理不同 API 返回的數據

當使用外部 API 或第三方套件時,有時候返回的數據可能包含不同的型別。在這種情況下,我們可以使用型別斷言來確保程式碼使用正確的型別。

interface ApiResponse {
  status: "success" | "error";
  data?: any;
  message?: string;
}

function handleApiResponse(response: ApiResponse) {
  if (response.status === "success") {
    // 沒有使用型別斷言
    let data1 = response.data; // `data1` 的型別為 `any`,編譯器無法提供有關其屬性或方法的提示

    // 使用型別斷言
    let data2 = response.data as { name: string, age: number }; // 確保編譯器將 `response.data` 視為具有 `name` 和 `age` 屬性的物件
  } else {
    console.error(response.message);
  }
}

要注意使用型別斷言的同時也要謹慎,因為它會跳過 TypeScript 的型別檢查。如果斷言不正確,可能會導致意外的行為和錯誤。因此在使用型別斷言時,應確定自己對目標值的型別有足夠的了解。