跳到主要内容
返回

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