在 production 的 React、Next 與 Hono code 裡,class 不是組織 application code 的方式。它是一種 runtime identity:一個 constructor、一個 prototype,以及一個 instanceof 能認出來的 object。
這篇 note 是 JavaScript 核心概念 沒有覆蓋的 object model。那篇講的是 code 何時執行。這篇講的是 class 是什麼,以及它何時值得寫:instanceof、一份被共享的可變 lifetime,或一個作為單一 object 的 client。否則用 types 與 functions。Application code consume 的 classes 比它寫的多。
1. Runtime
TypeScript class 是一個 constructor function、掛在該 constructor 上的 prototype object,以及 new 建立出來的 instances。
class Counter {
count = 0
inc(): number {
this.count += 1
return this.count
}
}
const a = new Counter()
const b = new Counter()
a.inc() // 1
b.inc() // 1 — separate instance fields
a.inc === b.inc // true — one shared method on Counter.prototype
a.inc === Counter.prototype.inc // truenew Counter() → instance -([[Prototype]])→ Counter.prototype -([[Prototype]])→ Object.prototype- 寫在 class body 裡的 methods 裝在 prototype 上。在 constructor 裡賦值的 fields,或 instance fields,活在每個 instance 上。
staticmembers 活在 constructor 上。 new建立一個普通 object、把[[Prototype]]設為Class.prototype、以新 object 為this跑 constructor,然後回傳它(除非 constructor 回傳了另一個 object)。- 不帶
new呼叫 class constructor 會 throw。Class bodies 永遠是 strict mode。 - Closure 可以在沒有 prototype 的情況下藏起同樣可變的
count—— JavaScript 核心概念 就是那樣做 counter —— 但它不提供instanceof,也不跨 instances 共享 methods。
2. Chain
extends 接上兩條 prototype chains:child 的 instances 在 Child.prototype 上找 methods,然後 Parent.prototype,再然後 Object.prototype。Constructors 以同樣方式相連,所以 Child 能看見 Parent 的 static members。
class Animal {
move(): string {
return "moved"
}
}
class Bird extends Animal {
fly(): string {
return "flew"
}
}
const bird = new Bird()
bird instanceof Bird // true
bird instanceof Animal // true
bird.move() // "moved" — found on Animal.prototype
const lookalike = { move: () => "moved", fly: () => "flew" }
lookalike instanceof Bird // falseinstanceof沿著 instance chain 走,找到對的.prototype就回傳 true。這是 runtime 測試,不是 TypeScript 測試。這條 walk 會到Object.prototype,所以bird instanceof Object為 true。super()在 child 碰this之前跑 parent constructor。派生 class 裡忘了它是 syntax error。- Type-level
extends—— constraint、ternary、infer—— 見 TypeScript Infer、Extends 與 Ternaries。這節講的是 prototype chain。 noImplicitOverride要求 child method 取代 parent method 時寫override。那個 flag 見 TypeScript Beyond Strict。- Failure: target 太舊,compile 之後 prototype chain 就沒了。Target ES2015 或更新。長得一樣的 plain object 帶同樣 fields,
instanceof仍會失敗。
3. this
Prototype method 裡的 this 是 method 被 呼叫時所在 的 object,不是 function 被定義時所在的 object。把 method 抽出來,binding 就沒了。
class Button {
label = "save"
onClick(): string {
return this.label
}
onClickBound = (): string => this.label
}
const button = new Button()
button.onClick() // "save"
button.onClickBound() // "save"
const { onClick, onClickBound } = button
onClickBound() // "save" — arrow captured the instance
onClick() // TypeError: Cannot read properties of undefined- Class bodies 是 strict,所以丟失的
this是undefined,不是 global object。 - 把
button.onClick傳給addEventListener或 ReactonClickprop 時,同樣會丟。.bind(button)、arrow field,或 wrapper() => button.onClick()都能修好。 - Arrow fields 在 construction 時 close over instance。它們不上 prototype;每個 instance 拿到自己的 function。
- Failure: 目標是一份共享的 prototype function,卻寫成 arrow field。Prototype method 更便宜去共享。Arrow field 更便宜去傳來傳去。
4. Types vs Values
class 宣告創造出兩樣東西:給 new 與 instanceof 用的 value,以及給 annotations 用的 type。interface 或 type alias 只創造 type。它會被 erased。
interface Clock {
now(): number
}
class SystemClock implements Clock {
now(): number {
return Date.now()
}
}
class Secret {
private compileTime = "visible after emit"
#runtime = "hidden"
reveal(): string {
return this.#runtime
}
}
const clock: Clock = new SystemClock()
clock instanceof SystemClock // true
// clock instanceof Clock // not a value; does not exist at runtime
const secret = new Secret()
secret.reveal() // "hidden"
// secret.compileTime // type error; still a property on the object at runtime
// secret.#runtime // syntax error in JavaScript tooimplements是 compile-time 檢查,確認 instance 形狀吻合。它不改變 runtime object。- TypeScript
private與protected是同一種檢查:compiler 拒絕obj.secret,然後把 keyword erase 掉。#fields 是 JavaScript,emit 之後仍然 private。 - Constructor parameter properties(
constructor(private name: string))與enum會 emit values。erasableSyntaxOnly禁止它們,好讓 types 能乾淨地 strip。那個 flag 見 TypeScript Beyond Strict。 - 如果檢查必須在
catchblock、JSON.parse之後,或跨 bundle boundary 仍能工作,它必須是 value:instanceof、discriminant field(type: "error"),或 branded function。 - Failure: 對一個 runtime 從不存在的 interface 做
instanceof。
5. Error Subclasses
Application code 裡出現的 class,幾乎總是 Error subclass。catch 得到的是 unknown。instanceof 把 domain failure 與 programmer error 分開,也把一種 domain failure 與另一種分開。
class AppError extends Error {
constructor(
readonly code: string,
message: string,
options?: ErrorOptions
) {
super(message, options)
this.name = "AppError"
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super("not_found", `${resource} ${id} not found`)
this.name = "NotFoundError"
}
}
try {
throw new NotFoundError("user", "missing")
} catch (error) {
if (error instanceof NotFoundError) error.code // "not_found"
else if (!(error instanceof AppError)) throw error
}- 額外 fields ——
code、HTTP status、cause—— 騎在 instance 上。Plain object{ message, code }被 throw 之後做不了那個測試。 - Failure 是 return value 時,result type 上的 string union 往往更好。那一半見 TypeScript Error Handling。
- Failure: throw 一個 plain object。一旦被
throw,能活下來的測試是 prototype chain。
6. Stateful Clients and Stores
當 一個 object 必須持有可變 lifetime 時,class 才值得寫:token、abort controller、connection、in-memory cache。Methods 共享那份 state。Callers 共享那個 object。
class ApiClient {
private token: string | null = null
constructor(private readonly baseUrl: string) {}
setToken(token: string): void {
this.token = token
}
async get<T>(path: string): Promise<T> {
const headers: HeadersInit = this.token
? { Authorization: `Bearer ${this.token}` }
: {}
const response = await fetch(`${this.baseUrl}${path}`, { headers })
if (!response.ok) throw new AppError("http", `${response.status} ${path}`)
return response.json() as Promise<T>
}
}
const api = new ApiClient("https://api.example.com")
api.setToken("secret")- 這不是 React tree。Client 不 render。它與 SDK client 同一形狀:construct 一次、傳來傳去、呼叫 methods、讓 fields 就地改變。
- Module-level closure 也能持有
token。當 callers 需要 construct 不止一個 —— 第二個 base URL、一個 test double、每個 tenant 一個 client —— 或需要instanceof ApiClient時,class 才合適。 - 永遠只會有一個、而且用不到 prototype 時,closure 合適。
7. Classes Are Consumed More Than They Are Written
TypeScript 程式裡的大多數 classes 並不是在那個程式裡寫的。Error、Map、Set、Date、Response、URL、EventTarget、DOM nodes、SDK clients。對著那些 types 做 instanceof 是普通 control flow。寫一個對得上的 class 則不是。
- Host 與 standard library 已經在 identity 與
instanceof重要的地方選了 classes:JSON.parse之後的error instanceof SyntaxError,event.currentTarget instanceof HTMLButtonElement。 - Application code 在邊界繼承這個選擇 ——
catch、DOM events、fetch。 - Failure: 為明天就會變成 JSON 的 data 發明一套平行 hierarchy。
8. What Not to Class
React components、API payloads 與 Hono handlers 不需要 runtime identity。它們需要 type 與 function。
type User = {
id: string
name: string
}
function displayName(user: User): string {
return user.name
}
const user = JSON.parse('{"id":"1","name":"Ada"}') as User
displayName(user) // "Ada"- React class components 把 state 存在
this上,並親手 rebind event handlers。Hooks 把那份 state 挪到 Fiber。這個 stack 裡的新 UI 是 function。Rendering model 見 深入理解 React。 - 跨網路的 DTO 是 type。
JSON.parse回傳 plain object。掛在class User上的 methods 不會在,instanceof User會是 false。 - Hono handler 是從 context 到 response 的 function。Nest 風格的 controller classes 屬於另一套 stack。用 Hono 打造 Backend APIs 把 routes 保持為 functions。
- Failure: 為明天就會變成 JSON 的 data 發明一套 class hierarchy。
Takeaway
TypeScript class 是 constructor 加上 prototype。Instance fields 活在 object 上。Methods 活在 prototype 上。interface 與 TypeScript private 會被 erased。# 與 instanceof 不會。
當問題是要不要寫一個時:
- 這是否需要在
throw之後或跨 boundary 做instanceof? - 是否有一個 object 需要 callers 共享的可變 lifetime?
- 還是這只是 data 與一個 function? 第三種情況不需要 class。