第一週第四天:物件型別之介面與陣列型別

第一週第四天:物件型別之介面與陣列型別

學習資源


物件的型別——介面

什麼是介面(Interface)?

在物件導向的程式語言中,介面是一個很重要的概念,它是一種抽象結構,用來定義程式碼中物件、函數或類別(Classes)的結構和行為。在程式設計中,介面可以當作「契約」,確保程式碼遵循特定規範。

在 TypeScript 中的介面,是一種靈活、可重複使用的方式來描述和檢查程式碼中的各種型別,包括描述物件形狀、函式型別、類別實現、索引簽名和建構函式型別等。

其中「描述物件的形狀」是最常見的用途,描述物件的結構和預期的屬性。以下是一個使用介面的簡單範例:

interface Person {
  name: string;
  age: number;
}

const anna: Person = {
  name: "Anna",
  age: 30,
};

在這個例子中,我們首先定義了一個名為 Person 的介面,它具有兩個屬性:nameage。接著我們建立了一個名為 anna 的物件,並指定它的型別為 Person。這意味著 anna 物件必須符合 Person 介面定義的結構。

介面命名的慣例通常是使用 PascalCase(首字母大寫的駝峰式命名)。有些開發者喜歡在名稱前加上大寫的 I 作為前綴(如, IPerson),用以表示它是一個介面(Interface),這種命名方式在某些程式語言(如 C#)的開發者中比較常見。

靈光一閃:像是 anna 變數跟 Person 介面「打勾勾」說好要長一樣。

接下來是幾個不遵守 Person 介面「契約」的例子,它們都會在 TypeScript 編譯時報錯:

interface Person {
  name: string;
  age: number;
}

// 例子 1: 缺少 `age` 屬性
const person1: Person = {
  name: "Tom",
}; // 錯誤:Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.

// 例子 2: `name` 屬性的型別錯誤
const person2: Person = {
  name: 25,
  age: 30,
}; // 錯誤:Type 'number' is not assignable to type 'string'.

// 例子 3: 多出一個不屬於 `Person` 介面的屬性
const person3: Person = {
  name: "Jack",
  age: 28,
  job: "Developer",
}; // 錯誤:Object literal may only specify known properties, and 'job' does not exist in type 'Person'.

但實際開發時,物件的結構最常因應不同場景而屬性增減的情況,因此 TypeScript 也有其他屬性,讓介面的制定更彈性。

可選屬性(Optional Properties)

在一些情況下,有些屬性在物件上可能存在,也可能不存在,這時就可以將屬性標記為可選屬性。例如:註冊表單,一些欄位可能是選填的,而不是必填的。在這種情況下,我們就可以使用可選屬性來表示這些選填的欄位。

要定義可選屬性,可以在屬性名的後面加上 ? 符號:

interface Person {
  name: string;
  age: number;
  phone?: string; // 加上 ? 來定義可選屬性
}

const personWithPhone: Person = {
  name: "John",
  age: 30,
  phone: "123-456-7890",
};

// phone 為可選屬性,因此不會報錯
const personWithoutPhone: Person = {
  name: "Anna",
  age: 25,
};

唯獨屬性(Readonly Properties)

唯獨屬性表示一但物件被初始化,該屬性就不能再被修改。例如:當我們有一個具有唯一識別符(ID)的物件時,該 ID 在創建物件後不應該被改變。使用唯獨屬性可以確保這種不可變性。

要定義唯獨屬性,可以在屬性名前加上 readonly 關鍵字:

interface Person {
  readonly id: number;
  name: string;
  age: number;
}

const anna: Person = {
  id: 1,
  name: "Anna",
  age: 30,
};

在這個例子中,Person 介面包含了 idnameage 屬性。其中 id 屬性前面有 readonly 關鍵字,表示它是唯獨的。這意味著一旦 anna 物件被創建,id 屬性的值就不能被修改了。

以下是試圖修改 id 屬性的值時會出現的 TypeScript 編譯錯誤:

anna.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.

這樣,通過將屬性標記為唯獨,我們確保了在物件被創建後,該屬性的值不能被更改。這在需要保護某些數據或避免意外修改時非常有用。

索引屬性

書內用「任意屬性」容易跟任意值混淆,因此改以「索引屬性」來說明。

在某些情況下,我們可能無法確定物件上具有哪些屬性,或者物件可能具有動態生成的屬性。在這種情況下,索引屬性可以讓我們更靈活地描述物件的形狀。例如,當我們從 API 獲取數據,API 返回的物件可能包含一些未知的額外屬性,使用索引屬性可以讓我們更好地處理這些未知屬性。

使用索引簽名(Index Signatures)來描述可索引屬性:

interface Person {
  name: string;
  age: number;
  // 名稱不一定要是 key 只要看得懂就好
  [key: string]: any;
}

const anna: Person = {
  name: "Anna",
  age: 30,
  hobby: "reading",
  profession: "developer",
};

在這個例子中,Person 介面包含了 nameage 屬性,以及一個索引簽名 [key: string]: any。這意味著我們可以為 Person 類型的物件添加任意額外的屬性,只要它們的鍵是字串類型。在 anna 這個例子中,我們添加了 hobbyprofession 作為額外的屬性,而不會引起 TypeScript 編譯錯誤。

需要注意的是,一旦定義了可索引屬性,那麼確定屬性和可選屬性的型別都必須是它的型別的子型別。例如:

interface Person {
  name: string;
  age: number;
  // [key: string] 的屬性值型別改成 string
  [key: string]: string;
}

const anna: Person = {
  name: "Anna",
  age: 30,
  hobby: "reading",
  profession: "developer",
};

在這個例子中,我們將索引屬性 [key: string] 的屬性值型別改成 string,這時 TypeScript 會報以下錯誤資訊:

// Type '{ name: string; age: number; hobby: string; profession: string; }' is not assignable to type 'Person'.
//  Property 'age' of type 'number' is not assignable to string index type 'string'.

為什麼我們動 [key: string]: string 噴錯的卻是 ageage 是給數字 30 沒錯呀?

這是因為索引屬性其實也代表「任何屬性」,所以當我們使用 [key: string]: string 來定義介面的規範時,就表示物件裡的任何一個屬性值的型別都必須要是 string 或是 string 的子型別。

另外,一個介面中只能定義一個索引屬性。如果想讓索引屬性的屬性值有多個型別,像上面那個例子,可將索引屬性指定為聯合型別:

interface Person {
  name: string;
  age: number;
  [key: string]: string | number;
}

陣列的型別

在 TypeScript 中,陣列型別可以用多種方式定義:

使用「型別+方括號」表示法

let numbers: number[] = [1, 2, 3, 4, 5];
let names: string[] = ['Anna', 'Bob', 'Cathy'];

在這個例子中,number[] 表示數字陣列,string[] 表示字串陣列。我們可以在方括號中指定任何基本型別或自定義型別。

使用泛型(Generic)表示法

let numbers: Array<number> = [1, 2, 3, 4, 5];
let names: Array<string> = ['Anna', 'Bob', 'Cathy'];

這個例子中,Array<number>Array<string> 分別表示數字和字串陣列。泛型允許你在多個型別之間共享相同的結構,使得程式碼更具可複用性。在這種表示法中,我們可以將其他型別放入尖括號 <> 中,例如基本型別、自定義型別或介面。

關於泛型到底是什麼,以後我們會專門做一期影片為大家講解 會在後續的章節說明,這裡就先不糾結了

無論使用哪一種表示法,TypeScript 都會對陣列中的元素進行型別檢查。這意味著如果你嘗試將不符合指定型別的值添加到陣列中,TypeScript 將會報錯。例如:

let numbers: number[] = [1, 2, 3, 4, 5];
numbers.push('6'); // 🔴 錯誤:不能將類型 'string' 分配給類型 'number'。

使用介面表示一般陣列

// 定義一個介面
interface StringArray {
    [index: number]: string;
}
// 使用介面表示陣列
let animals: StringArray = ["Dog", "Cat", "Fish"];

在這個例子中,我們定義了一個 StringArray 介面,它表示一個數字索引的陣列,其元素類型為 string。然後,我們建立了一個名為 animals 的變數,將其類型設置為 StringArray,並將一個由字串組成的陣列賦值給它。

雖然我們可以用介面來描述陣列,但是不覺得這樣做有點「殺雞焉用牛刀」嗎?直接使用剛剛學的第一招「型別+方括號」表示法會更清楚:

let animals: string[] = ["Dog", "Cat", "Fish"];

然而,當我們需要描述具有多個屬性和方法的複雜數據結構時,其實用介面來表示陣列反而會讓數據組織和結構更清晰易讀。接下來的「類陣列物件」就是一個活生生的例子。

使用介面表示類陣列物件(Array-like Object)

「類陣列物件」是指具有某些類似陣列特性的物件,它們可以向陣列一樣使用索引存取元素,並且具有 length 屬性。

然而,它們不是真正的陣列,因此不具有陣列的所有方法(如 pushpopforEach 等)。如果需要使用這些方法,可以先將類陣列物件轉換為真正的陣列,例如使用 Array.from() 方法。

比如在 JavaScript 中,當在一個函數內部存取其參數時,可以使用 arguments 物件:

function sum() {
    // 🔴 錯誤:Type 'IArguments' is not assignable to type 'number[]'.
    // 'IArguments' is missing the following properties from type 'number[]': pop, push, concat, join, and 24 more.
    let args: number[] = arguments;
    let result = 0;

    for (let i = 0; i < args.length; i++) {
        result += args[i];
    }

    return result;
}

console.log(sum(1, 2, 3, 4, 5));

在這個例子中,TypeScript 會報錯,提示 IArguments 類型不能賦值給 number[] 類型,原因如剛剛說的 arguments 實際上是一個類陣列物件,而不是真正的陣列。

IArguments 是什麼?

IArguments 是 TypeScript 中一個內建的介面,用於描述類陣列物件(Array-like Object),特別是用於描述 JavaScript 函數中的 arguments 物件。它在 TypeScript 的標準庫 lib.d.ts 中定義,大致如下:

interface IArguments {
    [index: number]: any;
    length: number;
    callee: Function;
    caller: Function;
}

除了 IArguments 外,還有其他一些常見的類陣列物件都有對應的介面,例如:

  1. NodeList:當你使用 document.querySelectorAll 或其他類似方法選擇 DOM 元素時,會返回一個 NodeList 類陣列物件。它表示一組節點(Node)。

     const nodeList = document.querySelectorAll("div");
    
  2. HTMLCollection:當你使用 document.getElementsByClassNamedocument.getElementsByTagName 等方法選擇 DOM 元素時,會返回一個 HTMLCollection 類陣列物件。它表示一組 HTML 元素。

     const htmlCollection = document.getElementsByClassName("some-class");
    

這些類陣列物件都有共同的特點:它們具有數字索引和 length 屬性,可以像陣列一樣遍歷,但並非真正的陣列。

用任意型別 any 表示大雜燴陣列

這在一些特殊情況下(懶得修錯誤)可能會用到,例如當你不確定陣列中的元素類型,或者陣列中的元素可能有多種不同的類型。

let mixedArray: any[] = [1, "hello", true, { name: "John" }, [1, 2, 3]];

// 可以在陣列中加入任何類型的元素
mixedArray.push(42);
mixedArray.push("world");
mixedArray.push({ age: 30 });

在這個例子中,我們定義了一個 mixedArray 變數,它的類型為 any[]。這表示 mixedArray 是一個陣列,其中的元素可以是任何類型。我們初始化時將不同類型的元素放入陣列中,並且可以在之後隨意添加其他類型的元素。