TypeScript 類別與物件:型別加持的物件導向程式設計

2025年7月25日

💎 加入 E+ 成長計畫 與超過 700+ 位工程師一同在社群成長,並獲得更多深度的軟體前後端學習資源

上一篇文章中,我們探討了介面如何描述資料的結構。介面很擅長定義物件應該具備什麼樣子的約定。但有件重要的事情它們做不到。

假設我們在為 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,我們無法保證類別實際提供其介面承諾的方法。

privateprotectedpublic:控制類別內部的存取

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 透過存取修飾符走得更遠:privateprotectedpublicreadonly。這些提供編譯期間保護和更多彈性。

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 關鍵字做兩件事:

  1. 我們無法建立抽象類別的實例。嘗試執行 new Content("Title") 會給我們編譯錯誤。

  2. 抽象方法必須被子類別實作。如果類別擴展這個抽象類別但忘記實作 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 確保 TutorialCodeExample 都實作 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 確保我們的類別實際執行這些事情。我們在編譯期間而非執行期間捕捉到缺少的方法。

存取修飾符讓我們控制物件的哪些部分可以被存取。privateprotectedpublicreadonly 在物件的內部狀態周圍建立編譯期間邊界。JavaScript 做不到這點。

抽象類別讓我們定義共享結構和行為,但阻止建立過於通用的基礎物件。它們保證子類別實作必要方法,同時確保我們無法建立不完整基礎類別的實例。

這些功能協同工作,給我們一個型別安全的物件導向程式設計體驗,遠超越 JavaScript 能提供的。

開始尋找需要保證約定(implements)、控制內部存取(存取修飾符),或共享結構與強制特化(抽象類別)的地方。這些就是 TypeScript 類別真正發光的地方。


加入 E+ 成長計畫

如果你覺得這篇內容有幫助,喜歡我們的內容,歡迎加入 ExplainThis 籌辦的 E+ 成長計畫,透過每週的深度主題文,以及技術討論社群,讓讀者在前端、後端、軟體工程的領域上持續成長。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們