跳至主要內容
返回

TypeScript Class 與 Runtime Identity

TypeScript

class 是 constructor 加上 prototype。需要 instanceof 或可變 lifetime 時才值得寫;否則用 types 與 functions

在 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。


ts
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 // true

text
new Counter() → instance -([[Prototype]])→ Counter.prototype -([[Prototype]])→ Object.prototype

  • 寫在 class body 裡的 methods 裝在 prototype 上。在 constructor 裡賦值的 fields,或 instance fields,活在每個 instance 上。static members 活在 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。


ts
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 // false

  • instanceof 沿著 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 就沒了。


ts
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 或 React onClick prop 時,同樣會丟。.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。


ts
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 too

  • implements 是 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。
  • 如果檢查必須在 catch block、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 與另一種分開。


ts
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。


ts
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。


ts
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 不會。

當問題是要不要寫一個時:

  1. 這是否需要在 throw 之後或跨 boundary 做 instanceof?
  2. 是否有一個 object 需要 callers 共享的可變 lifetime?
  3. 還是這只是 data 與一個 function? 第三種情況不需要 class。

Recap Q&A