TypeScript 類別(Classes)—— 公開(public)/ 私有(private)/ 受保護(protected)存取修飾子
簡介
在面向物件程式設計(OOP)中,類別是封裝資料與行為的核心概念。
TypeScript 在 ES6 之上加入了靜態型別與存取修飾子(access modifiers),讓開發者可以在編譯階段就捕捉到不當的成員使用,提升程式的可維護性與安全性。
public、private 與 protected 三種修飾子決定了類別成員(屬性或方法)在 外部、子類別以及同一個類別內 的可見度。掌握它們的差異,不僅能避免意外的資料洩漏,還能在大型專案中建立清晰的介面與實作分離,讓程式碼更易於閱讀與測試。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整闡述這三種存取修飾子的使用方式,幫助 初學者到中階開發者 在 TypeScript 中寫出更健全的類別。
核心概念
1. public(預設)
- 說明:未加修飾子或明確標註
public的成員,任何地方都可以直接存取。 - 特性:最寬鬆的存取權限,常用於提供外部呼叫的 API。
class User {
// 預設為 public
name: string;
// 明確寫出 public
public age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
// 任何程式都能呼叫
greet(): void {
console.log(`Hello, I'm ${this.name}, ${this.age} years old.`);
}
}
const alice = new User('Alice', 28);
alice.greet(); // Hello, I'm Alice, 28 years old.
console.log(alice.name); // Alice
alice.age = 29; // 可以直接修改
重點:若不想限制成員的可見度,直接使用
public(或省略)即可。
2. private(私有)
- 說明:僅能在 宣告它的類別內部 被存取,外部或子類別皆不可直接使用。
- 特性:用於隱藏實作細節、保護資料不被外部誤修改。
class BankAccount {
private balance: number = 0; // 私有屬性
constructor(public owner: string) {}
// 公開的存款方法,內部才能修改 balance
deposit(amount: number): void {
if (amount <= 0) throw new Error('Deposit amount must be positive');
this.balance += amount;
}
// 公開的查詢方法,允許外部讀取但不直接存取
getBalance(): number {
return this.balance;
}
}
const acct = new BankAccount('Bob');
acct.deposit(500);
console.log(acct.getBalance()); // 500
// console.log(acct.balance); // ❌ 編譯錯誤:Property 'balance' is private
小技巧:若需要在類別外部只讀的屬性,可將屬性設為
private,再提供public get存取子(getter)。
3. protected(受保護)
- 說明:在 宣告它的類別 以及 所有子類別 中皆可存取,外部仍不可直接使用。
- 特性:適合「讓子類別延伸或覆寫」的情境,同時保護成員不被外部濫用。
class Animal {
protected name: string; // 受保護屬性
constructor(name: string) {
this.name = name;
}
// 受保護方法,子類別可以呼叫
protected makeSound(sound: string): void {
console.log(`${this.name} says: ${sound}`);
}
}
class Dog extends Animal {
constructor(name: string) {
super(name);
}
bark(): void {
// 直接使用父類別的受保護屬性與方法
this.makeSound('Woof!');
}
}
const dog = new Dog('Lucky');
dog.bark(); // Lucky says: Woof!
// console.log(dog.name); // ❌ 編譯錯誤:Property 'name' is protected
注意:
protected讓子類別可以「看到」父類別的內部實作,但仍保持對外部的封裝。
4. 結合 readonly 與存取修飾子
readonly 可與任意修飾子搭配,讓屬性在 建構後不可再被賦值,常用於「常數」或「唯一識別碼」。
class Product {
public readonly id: number; // 只能在建構子內設定
private _price: number;
constructor(id: number, price: number) {
this.id = id;
this._price = price;
}
get price(): number {
return this._price;
}
set price(value: number) {
if (value < 0) throw new Error('Price cannot be negative');
this._price = value;
}
}
const p = new Product(101, 199);
console.log(p.id); // 101
// p.id = 202; // ❌ 編譯錯誤:Cannot assign to 'id' because it is a read-only property.
p.price = 149; // 透過 setter 調整
程式碼範例彙總
以下提供 五個實用範例,展示如何在真實開發中靈活運用 public、private、protected,以及它們的組合。
| 範例編號 | 主題 | 重點說明 |
|---|---|---|
| 1 | API 回傳模型 | 使用 public readonly 定義不可變的欄位,保證資料一致性。 |
| 2 | Singleton(單例) | private 建構子防止外部實例化,public static 取得唯一實例。 |
| 3 | 事件系統基礎類別 | protected 讓子類別自行註冊與觸發事件。 |
| 4 | 資料驗證器 | private 方法封裝驗證邏輯,public 方法提供外部介面。 |
| 5 | 繼承與多型 | protected 屬性與抽象方法結合,實作多型行為。 |
範例 1:API 回傳模型
class UserDto {
// 只讀且公開,外部可以直接讀取但無法改寫
public readonly id: number;
public readonly username: string;
public readonly email: string;
constructor(data: { id: number; username: string; email: string }) {
this.id = data.id;
this.username = data.username;
this.email = data.email;
}
}
// 使用情境
function fetchUser(): UserDto {
// 假設從 API 取得資料
const data = { id: 7, username: 'john_doe', email: 'john@example.com' };
return new UserDto(data);
}
const user = fetchUser();
console.log(user.id); // 7
// user.id = 8; // ❌ 編譯錯誤
範例 2:Singleton(單例)模式
class Logger {
// 私有建構子阻止外部 new
private constructor() {}
private static _instance: Logger | null = null;
// 公開靜態方法取得唯一實例
public static getInstance(): Logger {
if (!Logger._instance) {
Logger._instance = new Logger();
}
return Logger._instance;
}
public log(message: string): void {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}
}
// 使用方式
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
logger1.log('First message');
console.log(logger1 === logger2); // true
範例 3:事件系統基礎類別(受保護成員)
type Listener = (...args: any[]) => void;
class EventEmitter {
// 受保護的 listeners,子類別可直接操作
protected listeners: Map<string, Listener[]> = new Map();
public on(event: string, fn: Listener): void {
const arr = this.listeners.get(event) ?? [];
arr.push(fn);
this.listeners.set(event, arr);
}
protected emit(event: string, ...args: any[]): void {
const arr = this.listeners.get(event);
if (arr) {
arr.forEach((fn) => fn(...args));
}
}
}
// 子類別自行定義特定事件
class Button extends EventEmitter {
click(): void {
console.log('Button clicked');
this.emit('click'); // 受保護的 emit 可直接呼叫
}
}
const btn = new Button();
btn.on('click', () => console.log('Handler 1'));
btn.on('click', () => console.log('Handler 2'));
btn.click();
// Output:
// Button clicked
// Handler 1
// Handler 2
範例 4:資料驗證器(私有輔助函式)
class EmailValidator {
// 公開 API
public static isValid(email: string): boolean {
return this.checkFormat(email) && this.checkDomain(email);
}
// 私有輔助方法,外部不可直接呼叫
private static checkFormat(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
private static checkDomain(email: string): boolean {
const allowed = ['example.com', 'test.org'];
const domain = email.split('@')[1];
return allowed.includes(domain);
}
}
// 使用
console.log(EmailValidator.isValid('alice@example.com')); // true
// EmailValidator.checkFormat('alice@example.com'); // ❌ 編譯錯誤
範例 5:繼承與多型(抽象類別 + protected)
abstract class Shape {
// 受保護的屬性讓子類別直接使用
protected color: string;
constructor(color: string) {
this.color = color;
}
// 抽象方法,子類別必須實作
abstract area(): number;
public describe(): void {
console.log(`A ${this.color} shape with area ${this.area()}`);
}
}
class Circle extends Shape {
constructor(public radius: number, color: string) {
super(color);
}
// 實作抽象方法
area(): number {
return Math.PI * this.radius ** 2;
}
}
const c = new Circle(5, 'red');
c.describe(); // A red shape with area 78.53981633974483
// c.color = 'blue'; // ❌ 編譯錯誤:Property 'color' is protected
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解決方案 |
|---|---|---|
忘記 private / protected 仍會在 JavaScript 中可見 |
TS 只在編譯階段檢查,最終產出的 ES5/ES6 仍是公開屬性。 | 使用 #私有欄位(ES2022)或 WeakMap 方式加強執行時隱私;同時在程式碼審查時保持嚴格的存取規範。 |
子類別意外修改父類別的 protected 成員 |
protected 允許子類別直接寫入,若未加限制可能破壞父類別不變性。 |
盡量將可變的資料設為 private,提供 protected getter 或 protected setter,讓子類別只能透過受控方式修改。 |
過度使用 public |
所有屬性皆公開會讓類別變成「資料袋」(data‑bag),失去封裝的意義。 | 先思考是否真的需要外部直接存取;若只是讀取,使用 private + public getter;若需要寫入,考慮 private + public setter 或 protected。 |
在介面(interface)中使用 private / protected |
介面只能描述公開結構,無法宣告存取修飾子。 | 若需要限制實作,改用 抽象類別(abstract class)配合 protected。 |
混用 public 與 readonly 產生誤解 |
public readonly 看似「可寫」但實際只能在建構子內賦值。 |
在文件或註解中說明「只能在建構時設定」;必要時使用 getter 取代直接屬性。 |
最佳實踐清單
- 預設使用
private:除非有明確需求,先將屬性設為私有,再決定是否暴露 getter/setter。 - 僅在需要被子類別存取時使用
protected,且盡量提供受控的抽象方法或保護式存取子。 public成員應盡量保持不變(使用readonly),避免外部隨意改寫。- 保持一致的命名慣例:私有屬性可使用底線前綴(
_balance)或#私有欄位,以提升可讀性。 - 使用 TypeScript 的
strict編譯選項,確保存取修飾子在編譯時被嚴格檢查。
實際應用場景
1. 前端 UI 元件庫
在開發一套可重用的 UI 元件(如 Button、Modal)時:
public:提供外部呼叫的屬性(如label、disabled)以及事件回呼(onClick)。private:內部狀態(如動畫計時器、DOM 參考)不允許外部直接操作。protected:若允許客製化子類別(例如PrimaryButton繼承自Button),則將樣式相關的屬性(如baseClass)設為protected,讓子類別可以擴充而不暴露給最終使用者。
2. 後端服務的資料模型
在使用 ORM(如 TypeORM) 時:
public:資料庫欄位必須公開,以便 ORM 自動映射。private:敏感資訊(如passwordHash)應設為私有,並提供public的驗證方法。protected:若有多層繼承的模型(如BaseEntity→User→AdminUser),共用的欄位(createdAt、updatedAt)可設為protected,讓子類別自行決定是否公開。
3. 企業級業務規則引擎
業務規則往往需要 封裝 與 擴充:
- 使用
abstract class+protected方法 定義規則的骨架,子類別實作具體判斷邏輯。 private方法用於內部計算或快取,防止外部誤用。- 最終的
public執行介面只接受必要的參數,返回結果,保持 API 的穩定性。
總結
public、private、protected 是 TypeScript 類別最基礎卻最關鍵的存取控制機制。透過正確的使用:
- 提升封裝性:隱藏不應被外部直接操作的實作細節。
- 增進可維護性:讓類別的介面保持穩定,未來修改內部實作不會破壞使用者程式碼。
- 支援繼承與多型:
protected為子類別提供必要的延伸點,同時保護資料不被濫用。
在實務開發中,建議 以最嚴格的存取修飾子開始(即 private),再根據需求逐步放寬。結合 readonly、抽象類別與介面,能建立既安全又彈性的物件模型,為大型 TypeScript 專案奠定堅實基礎。
最後提醒:存取修飾子僅在編譯階段提供安全保證,若有真正的執行時隱私需求,請考慮使用 #私有欄位 或其他封裝技巧。祝你在 TypeScript 的世界裡寫出乾淨、可讀、可擴充的程式碼!