學習資源
物件的型別——介面
什麼是介面(Interface)?
在物件導向的程式語言中,介面是一個很重要的概念,它是一種抽象結構,用來定義程式碼中物件、函數或類別(Classes)的結構和行為。在程式設計中,介面可以當作「契約」,確保程式碼遵循特定規範。
在 TypeScript 中的介面,是一種靈活、可重複使用的方式來描述和檢查程式碼中的各種型別,包括描述物件形狀、函式型別、類別實現、索引簽名和建構函式型別等。
其中「描述物件的形狀」是最常見的用途,描述物件的結構和預期的屬性。以下是一個使用介面的簡單範例:
interface Person {
name: string;
age: number;
}
const anna: Person = {
name: "Anna",
age: 30,
};
在這個例子中,我們首先定義了一個名為 Person
的介面,它具有兩個屬性:name
和 age
。接著我們建立了一個名為 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
介面包含了 id
、name
和 age
屬性。其中 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
介面包含了 name
和 age
屬性,以及一個索引簽名 [key: string]: any
。這意味著我們可以為 Person
類型的物件添加任意額外的屬性,只要它們的鍵是字串類型。在 anna
這個例子中,我們添加了 hobby
和 profession
作為額外的屬性,而不會引起 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
噴錯的卻是 age
?age
是給數字 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
屬性。
然而,它們不是真正的陣列,因此不具有陣列的所有方法(如 push
、pop
、forEach
等)。如果需要使用這些方法,可以先將類陣列物件轉換為真正的陣列,例如使用 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
外,還有其他一些常見的類陣列物件都有對應的介面,例如:
NodeList
:當你使用document.querySelectorAll
或其他類似方法選擇 DOM 元素時,會返回一個NodeList
類陣列物件。它表示一組節點(Node)。const nodeList = document.querySelectorAll("div");
HTMLCollection
:當你使用document.getElementsByClassName
、document.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
是一個陣列,其中的元素可以是任何類型。我們初始化時將不同類型的元素放入陣列中,並且可以在之後隨意添加其他類型的元素。