TypeScript 類別與物件:型別加持的物件導向程式設計
2025年7月25日
在上一篇文章中,我們探討了介面如何描述資料的結構。介面很擅長定義物件應該具備什麼樣子的約定。但有件重要的事情它們做不到。
假設我們在為 ExplainThis 建立一個教學系統。我們可能會從這個介面開始:
interface Tutorial {
title: string;
isPublished: boolean;
}
看起來不錯。但接著我們發現需要讓教學內容能夠真正_執行_一些操作。發布自己、產生閱讀時間估算、驗證自己的內容。
如果只用介面,最後會需要為所有功能寫分離的函式:
function publishTutorial(tutorial: Tutorial) {
/* ... */
}
function calculateReadingTime(tutorial: Tutorial) {
/* ... */
}
function validateTutorialContent(tutorial: Tutorial) {
/* ... */
}
這樣可以運作,但很麻煩。我們的教學資料在一個地方,所有教學行為卻在別的地方。我們必須記住哪些函式對應哪些物件。當我們在程式碼中看到 Tutorial
物件時,沒有任何跡象表明 publishTutorial()
和 calculateReadingTime()
函式的存在。
類別透過將資料和行為綁在一起來解決這個問題。當我們從類別建立教學物件時,它會內建相關行為。我們不需要到處尋找正確的函式。
不過有個重要觀念要理解:TypeScript 類別不只是在 JavaScript 類別上撒一些型別註釋。JavaScript 從 ES6 開始就有類別,但功能很基本。我們可以建立物件、新增方法、擴展其他類別,大概就這樣。
TypeScript 增加了三個 JavaScript 完全沒有的功能。這些功能徹底改變了我們建立應用程式的方式。它們給我們編譯期間的保證,確保物件會如我們期望的方式運作。
implements
:保證類別實現介面的承諾
想想我們可能遇過的問題。我們定義了一個介面,說「可以儲存的物件應該有 save()
方法」:
interface Saveable {
save(): void;
}
然後我們寫了一個類別:
class Tutorial {
content: string = "";
// 糟糕,忘記加 save()!
}
TypeScript 不會抱怨。我們的介面存在,類別也存在,但它們之間沒有連接。介面只是在那裡,描述可儲存的東西應該長什麼樣子。類別在做自己的事。它們沒有對話。
我們只有在嘗試呼叫 save()
並得到執行期間錯誤時,才會發現有問題。到那時,我們的程式碼已經在正式環境中執行,某個人的教學內容可能沒有被儲存。
這時候 implements
關鍵字登場了。它在介面和類別之間建立約定。強制我們的類別實際提供介面承諾的所有東西:
interface Saveable {
save(): void;
isDirty(): boolean;
}
class Tutorial implements Saveable {
private content: string = "";
private modified: boolean = false;
save(): void {
this.modified = false;
}
isDirty(): boolean {
return this.modified;
}
}
現在如果我們忘記加入 save()
或 isDirty()
,TypeScript 會立即警告我們。編輯器會出現紅色波浪底線,顯示類似「Class 'Tutorial' incorrectly implements interface 'Saveable'. Property 'save' is missing.」的訊息。我們甚至無法編譯程式碼,直到修好為止。
相比在執行期間才發現物件缺少應有方法,這是巨大的改進。與其收到說「Cannot read property 'save' of undefined」的正式環境錯誤報告,我們在寫程式碼時就捕捉到問題。
當我們有多個不同類別都實作同一個介面時,真正的威力就顯現了。想想看:ExplainThis 中可能有不同類型的內容。教學、部落格文章、程式碼範例。它們都需要能夠發布,但發布方式完全不同:
interface Publishable {
publish(): boolean;
getSlug(): string;
}
class Tutorial implements Publishable {
publish(): boolean {
return true;
}
getSlug(): string {
/* 教學專用的 slug */ return "";
}
}
class BlogPost implements Publishable {
publish(): boolean {
return true;
}
getSlug(): string {
/* 部落格專用的 slug */ return "";
}
}
在上面的例子中,兩個類別都保證有 publish()
和 getSlug()
方法。所以期望 Publishable
物件的程式碼不在乎拿到的是 Tutorial
還是 BlogPost
。它只知道介面會在那裡。
這就是 implements
的魔力。我們的介面說明某個東西應該能夠做什麼。我們的類別實際執行這些事情。而 implements
確保它們匹配。沒有 implements
,我們無法保證類別實際提供其介面承諾的方法。
private
、protected
、public
:控制類別內部的存取
JavaScript 多年來一直有隱私問題。直到最近,類別中的所有東西都是公開的。任何人都可以搞亂任何東西。
class BankAccount {
balance = 0;
deposit(amount) {
this.balance += amount;
}
}
const account = new BankAccount();
account.balance = 1000000; // 糟糕
這不太好。我們希望人們使用 deposit()
,而不是直接改變餘額。
現代 JavaScript(ES2022+)現在有使用 #
語法的私有欄位:
class BankAccount {
#balance = 0; // 私有欄位
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.#balance = 1000000; // 執行期間的 SyntaxError
但 JavaScript 的隱私功能有限。TypeScript 透過存取修飾符走得更遠:private
、protected
、public
和 readonly
。這些提供編譯期間保護和更多彈性。
class BankAccount {
private balance: number = 0;
public readonly owner: string;
protected accountNumber: string;
constructor(owner: string, accountNumber: string) {
this.owner = owner;
this.accountNumber = accountNumber;
}
deposit(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
讓我們分析每個修飾符的作用,以及它們與 JavaScript 的 #
欄位有何不同:
private
意味著只有這個類別可以觸碰它。沒有其他人可以直接存取balance
。他們必須使用deposit()
和getBalance()
。這阻止人們意外將餘額設為無效值。與 JavaScript 的#balance
不同,TypeScript 的private
在編譯期間檢查。public
(預設值)意味著任何人都可以存取它。deposit()
是公開的,因為這是銀行帳戶應該做的事。readonly
意味著我們可以讀取但無法在建立後更改它。我們可以檢查account.owner
但無法將它改為別人。JavaScript 的#
欄位沒有這個概念。protected
意味著這個類別和其子類別可以存取它,但其他人不行。也許特殊帳戶類型需要帳戶號碼,但一般程式碼不應該觸碰它。這比 JavaScript 的私有欄位更靈活,後者完全無法被子類別存取。
關鍵差異是 TypeScript 的修飾符是編譯期間保護。TypeScript 在程式碼執行前就阻止我們寫出違反這些規則的程式碼。JavaScript 的 #
欄位則拋出執行期間錯誤。
這意味著什麼?如果我們嘗試在 TypeScript 中存取私有屬性,編輯器會立即亮起紅色錯誤訊息。我們甚至無法編譯程式碼。使用 JavaScript 的 #
欄位,程式碼編譯正常,但實際執行時會崩潰:
const account = new BankAccount("Sarah", "123456");
account.deposit(100);
console.log(account.getBalance()); // ✅ 100
console.log(account.owner); // ✅ "Sarah"
// 這些被 TypeScript 阻止:
account.balance = 500; // ❌ Error: Property 'balance' is private
account.owner = "Bob"; // ❌ Error: Cannot assign to 'owner' because it's readonly
account.accountNumber; // ❌ Error: Property 'accountNumber' is protected
這種編譯期間檢查意味著我們在寫程式碼時就捕捉到隱私違規,而不是在使用者嘗試使用我們的應用程式時。我們得到了防彈的封裝。我們的物件完全控制自己的內部狀態。如果程式碼嘗試破壞這種控制,我們會立即發現。
abstract
:強制實作的類別
這裡有個令人挫折的問題。假設我們在建立 ExplainThis 的內容系統。我們有教學、部落格文章、程式碼範例。它們都共享一些東西:標題、發布日期、能夠發布或取消發布的能力。
所以我們建立一個基礎 Content
類別:
class Content {
protected title: string;
protected publishDate: Date;
protected isPublished: boolean = false;
constructor(title: string) {
this.title = title;
this.publishDate = new Date();
}
publish(): void {
this.isPublished = true;
}
// 但我們這裡要回傳什麼?
getDuration(): number {
return ???; // 教學有閱讀時間,程式碼範例有學習時間
}
// 這裡呢?
getDisplayInfo(): string {
return ???; // 每種內容類型顯示方式不同
}
}
我們卡住了。基礎類別需要 getDuration()
和 getDisplayInfo()
方法,因為所有內容類型都使用它們。但基礎實作應該回傳什麼?教學的持續時間是基於字數的閱讀時間。程式碼範例的持續時間是基於複雜度的學習時間。沒有有意義的基礎實作。
我們的選擇都很糟:
- 回傳假值如
0
(誤導) - 拋出執行期間錯誤(讓應用程式崩潰)
- 讓方法空白(破壞所有呼叫它們的東西)
更糟的是,有人可能意外建立一個純 Content
物件:
const content = new Content("某個標題");
console.log(content.getDuration()); // 這裡會發生什麼?
抽象類別完美解決了這個問題。它們讓我們定義共享結構和行為,但阻止建立不完整基礎類別的實例:
abstract class Content {
protected title: string;
protected publishDate: Date;
protected isPublished: boolean = false;
constructor(title: string) {
this.title = title;
this.publishDate = new Date();
}
publish(): void {
this.isPublished = true;
}
abstract getDisplayInfo(): string; // 必須被子類別實作
abstract getDuration(): number; // 必須被子類別實作
}
abstract
關鍵字做兩件事:
我們無法建立抽象類別的實例。嘗試執行
new Content("Title")
會給我們編譯錯誤。抽象方法必須被子類別實作。如果類別擴展這個抽象類別但忘記實作
getDisplayInfo()
,TypeScript 不會讓我們編譯。
現在我們可以建立實際的實作:
class Tutorial extends Content {
private wordCount: number;
constructor(title: string, wordCount: number) {
super(title);
this.wordCount = wordCount;
}
getDisplayInfo(): string {
return `教學:${this.title}`;
}
getDuration(): number {
return Math.ceil(this.wordCount / 200); // 閱讀時間(分鐘)
}
}
class CodeExample extends Content {
private lineCount: number;
constructor(title: string, lineCount: number) {
super(title);
this.lineCount = lineCount;
}
getDisplayInfo(): string {
return `程式碼範例:${this.title}`;
}
getDuration(): number {
return Math.ceil(this.lineCount / 10); // 學習時間(分鐘)
}
}
抽象類別給我們一般繼承做不到的東西:它們保證某些方法存在於所有子類別上,但我們無法建立抽象類別本身。TypeScript 確保 Tutorial
和 CodeExample
都實作 getDisplayInfo()
和 getDuration()
。如果它們沒有,我們會得到編譯錯誤。
這建立了一個很有用的模式。我們可以寫與任何 Content
類型一起運作的程式碼,而且我們知道某些方法總是會在那裡:
function displayContent(content: Content): void {
console.log(content.getDisplayInfo());
console.log(`持續時間:${content.getDuration()} 分鐘`);
}
const tutorial = new Tutorial("TypeScript 類別指南", 1000);
const codeExample = new CodeExample("介面實作", 50);
displayContent(tutorial); // 與 Tutorial 一起運作
displayContent(codeExample); // 與 CodeExample 一起運作
displayContent(new Content("Title")); // ❌ 錯誤:無法建立抽象類別
總結
TypeScript 類別不只是在 JavaScript 類別上加一些型別。TypeScript 增加了 JavaScript 完全沒有的關鍵功能。
implements
關鍵字連接我們的介面或型別到類別。介面說明物件應該能夠做什麼。implements
確保我們的類別實際執行這些事情。我們在編譯期間而非執行期間捕捉到缺少的方法。
存取修飾符讓我們控制物件的哪些部分可以被存取。private
、protected
、public
和 readonly
在物件的內部狀態周圍建立編譯期間邊界。JavaScript 做不到這點。
抽象類別讓我們定義共享結構和行為,但阻止建立過於通用的基礎物件。它們保證子類別實作必要方法,同時確保我們無法建立不完整基礎類別的實例。
這些功能協同工作,給我們一個型別安全的物件導向程式設計體驗,遠超越 JavaScript 能提供的。
開始尋找需要保證約定(implements
)、控制內部存取(存取修飾符),或共享結構與強制特化(抽象類別)的地方。這些就是 TypeScript 類別真正發光的地方。
加入 E+ 成長計畫
如果你覺得這篇內容有幫助,喜歡我們的內容,歡迎加入 ExplainThis 籌辦的 E+ 成長計畫,透過每週的深度主題文,以及技術討論社群,讓讀者在前端、後端、軟體工程的領域上持續成長。