TypeScript 类设计:修饰符、抽象类与接口契约

系列导航:TypeScript 7 现代开发指南
上一篇:类型组合
下一篇:泛型与类型运算

JavaScript 的 class 提供构造器、继承和真正的私有字段,TypeScript 7.0.2 在此基础上增加参数属性、可见性修饰符、readonlyimplements、抽象类和 override 检查。类最适合封装具有状态、行为和生命周期的对象;接口则描述调用方真正需要的能力。掌握两者的边界,才能避免把类型契约误当成运行时保护,也能避免为了复用几行代码建立脆弱的继承层级。

一、类同时带来运行时值和实例类型

类声明既会生成运行时可调用的构造器,也会创建实例侧类型:

class Counter {
  count = 0

  increment(step = 1): number {
    this.count += step
    return this.count
  }
}

const counter: Counter = new Counter()
counter.increment()

interfacetype 不同,Counter 在运行时真实存在,因此可以被 new、用于 instanceof,也可以拥有静态成员:

class User {
  static guestName = '访客'

  constructor(public name: string) {}
}

console.log(User.guestName)
console.log(new User('小林') instanceof User)

类名出现在类型位置时默认表示实例侧;typeof User 才表示包含构造器和静态成员的类本身。

二、参数属性减少重复声明

普通写法需要先声明字段,再在构造器中赋值:

class Article {
  id: string
  title: string

  constructor(id: string, title: string) {
    this.id = id
    this.title = title
  }
}

给构造器参数添加 publicprotectedprivatereadonly 后,参数会同时成为实例属性:

class Article {
  constructor(
    public readonly id: string,
    public title: string,
  ) {}

  rename(title: string) {
    this.title = title
  }
}

参数属性适合简单的数据承载类,但它会在构造器签名中同时声明字段。参数很多、初始化需要校验,或希望字段定义一眼可见时,显式声明往往更易读。

三、publicprotected 与 TypeScript private

TypeScript 提供三种访问级别:

修饰符 类内部 子类 类外部
public
protected
private

未写修饰符的类成员默认是 public

class Account {
  constructor(
    public displayName: string,
    protected accountId: string,
    private balance: number,
  ) {}

  public getBalance(): number {
    return this.balance
  }

  protected auditMessage(): string {
    return `${this.accountId}: ${this.balance}`
  }
}

class BusinessAccount extends Account {
  printAudit() {
    console.log(this.auditMessage())
    // console.log(this.balance) // 错误:private 仅当前类可见
  }
}

这些 TypeScript 修饰符主要在编译期限制访问。普通 private balance 编译成 JavaScript 后通常仍是常规属性,不能把它当作抵御恶意运行时代码的安全边界。

privateprotected 还会影响类类型之间的结构兼容:若两个类各自声明了私有或受保护成员,即使公开结构相同,也通常要求这些成员源自同一个类声明。这个规则让带私有状态的类具有一部分名义类型特征。

四、JavaScript #private 是运行时私有字段

需要由 JavaScript 引擎强制隔离的状态时,使用 # 私有字段:

class TokenVault {
  #token: string

  constructor(token: string) {
    this.#token = token
  }

  matches(token: string): boolean {
    return this.#token === token
  }
}

const vault = new TokenVault('secret')
vault.matches('secret')
// vault.#token // 语法错误:类外不可访问

#token 不只是 TypeScript 检查,它具有 JavaScript 规定的运行时私有语义,也不能通过 vault['#token'] 读取。它与 private token 不是两种拼写风格,而是不同的运行时模型。

如何选择:

  • 只需要 TypeScript 项目中的 API 可见性约束,可以使用 private
  • 需要运行时隐私,或对象可能被普通 JavaScript 代码接触,使用 #private
  • 子类需要访问时使用 protected#private 不能被子类直接访问,可通过受控的 protected 方法暴露必要行为。

五、readonly 约束初始化后的重新赋值

类字段可以在声明处或构造器中初始化只读值,之后不能重新赋值:

class Session {
  readonly createdAt = new Date()

  constructor(
    public readonly id: string,
    public user: { name: string },
  ) {}

  renameUser(name: string) {
    this.user.name = name
  }
}

const session = new Session('s_001', { name: '小林' })
// session.id = 's_002' // 错误
session.user.name = '林同学' // 允许:readonly 没有递归到对象内部

类中的 readonly 仍是 TypeScript 编译期、浅层约束。需要深层不可变数据时,应设计只读嵌套类型并选择合适的运行时冻结策略,而不是只给最外层字段加一个修饰符。

六、implements 检查实例契约

接口可以规定类实例必须提供哪些能力:

interface Serializable {
  serialize(): string
}

interface Renamable {
  rename(name: string): void
}

class Project implements Serializable, Renamable {
  constructor(public name: string) {}

  rename(name: string): void {
    this.name = name
  }

  serialize(): string {
    return JSON.stringify({ name: this.name })
  }
}

一个类可以实现多个接口。implements 只执行类型检查,不会复制实现,不会在运行时留下接口对象,也不会改变类本身的类型。

它也不会替类体补全类型推断。方法参数和返回值仍应显式、准确地声明,尤其是在 noImplicitAny 下:

interface Formatter {
  format(value: number): string
}

class PriceFormatter implements Formatter {
  format(value: number): string {
    return `¥${value.toFixed(2)}`
  }
}

implements 默认检查实例侧。若 API 接收的是“类构造器”而不是实例,应单独声明构造签名:

interface Task {
  run(): Promise<void>
}

type TaskConstructor = new (id: string) => Task

function createTask(TaskClass: TaskConstructor, id: string): Task {
  return new TaskClass(id)
}

七、抽象类共享状态和基础实现

抽象类不能直接实例化,但可以包含字段、构造器、已实现方法和要求子类实现的抽象成员:

abstract class Job {
  constructor(public readonly id: string) {}

  abstract run(): Promise<void>

  describe(): string {
    return `job:${this.id}`
  }
}

class EmailJob extends Job {
  override async run(): Promise<void> {
    console.log(`发送邮件任务 ${this.id}`)
  }
}

接口只描述结构,抽象类则能够共享实现和受保护状态。可以用下面的判断辅助选择:

需求 接口 抽象类
只描述能力,不提供实现 可行但偏重
一个类需要遵守多个契约 类只能继承一个基类
共享构造流程、状态或默认实现
希望普通对象也可满足契约 通常不合适
运行时需要基类构造器或 instanceof

抽象类适合确实存在“是一个”关系、子类可安全替代基类,并且共享实现具有稳定语义的场景。仅仅因为两个类碰巧有几行相同代码,不足以建立继承关系。

八、用 overridenoImplicitOverride 防止静默失配

派生类重写基类成员时,override 明确表达意图:

class ConsoleJob extends Job {
  override async run(): Promise<void> {
    console.log(this.describe())
  }
}

如果基类中不存在对应成员,override 会报错。这能捕获基类重命名后,子类方法悄悄变成无关新方法的问题。

建议在项目中开启 noImplicitOverride

{
  "compilerOptions": {
    "strict": true,
    "noImplicitOverride": true
  }
}

开启后,只要派生类成员覆盖了基类成员,就必须显式写 override。它不会阻止所有继承设计错误,但能让重写关系在代码审查和重构时清晰可见。

九、优先组合,谨慎继承

组合让对象依赖一个小契约,而不是继承完整实现:

interface MessageSender {
  send(message: string): Promise<void>
}

class NotificationService {
  constructor(private readonly sender: MessageSender) {}

  async welcome(name: string): Promise<void> {
    await this.sender.send(`欢迎,${name}`)
  }
}

const service = new NotificationService({
  async send(message) {
    console.log(message)
  },
})

NotificationService 不关心消息由邮件、短信还是测试替身发送。调用方只要满足 MessageSender 的结构即可,替换实现和单元测试都很直接。

继承更适合以下情况:

  • 子类在语义上确实是基类的一种,并能替代基类。
  • 基类维护稳定的不变量或生命周期模板。
  • 子类需要共享受保护状态或默认实现,而不只是复用工具函数。

出现多层继承、子类频繁覆盖父类细节、为了复用而继承不相关类时,应重新评估组合、委托或普通函数是否更简单。

十、结构契约让类与普通对象协作

接口描述的是能力,而不是来源。类实例和对象字面量都可以满足同一个契约:

interface Runnable {
  run(): Promise<void>
}

async function execute(task: Runnable): Promise<void> {
  await task.run()
}

execute(new EmailJob('email_001'))

execute({
  async run() {
    console.log('运行一次性任务')
  },
})

这种结构化兼容让函数依赖最小公开能力,而不必要求调用方继承某个基类。它也说明 implements 不是使用接口的必要条件:显式写 implements 有助于在类声明处尽早验证设计,但任何结构兼容的值都能交给 Runnable

私有成员是重要例外。带 TypeScript privateprotected 或 JavaScript #private 的类兼容性会受到成员来源约束,不能只比较公开字段后就认为任意两个类可互换。

十一、实战:接口负责边界,抽象类负责流程

下面让接口定义可替换的持久化能力,抽象类复用任务执行流程:

interface JobStore {
  save(id: string, status: 'done' | 'failed'): Promise<void>
}

abstract class PersistedJob {
  constructor(
    public readonly id: string,
    private readonly store: JobStore,
  ) {}

  protected abstract perform(): Promise<void>

  async execute(): Promise<void> {
    try {
      await this.perform()
      await this.store.save(this.id, 'done')
    } catch (error: unknown) {
      await this.store.save(this.id, 'failed')
      throw error
    }
  }
}

class ReportJob extends PersistedJob {
  protected override async perform(): Promise<void> {
    console.log(`生成报表 ${this.id}`)
  }
}

const memoryStore: JobStore = {
  async save(id, status) {
    console.log(id, status)
  },
}

await new ReportJob('report_001', memoryStore).execute()

JobStore 是结构契约,测试时可以传入普通对象;PersistedJob 则封装成功与失败都要持久化的稳定流程。子类只实现变化点 perform(),并通过 override 明确重写关系。

十二、常见陷阱

1. 把 TypeScript private 当作安全机制

它主要阻止类型检查通过,运行时强隔离应使用 JavaScript #private。无论哪一种,都不应在前端对象中保存真正的服务端秘密。

2. 误以为 implements 会生成或继承代码

接口会在编译后擦除。implements 只检查实例结构,默认不检查静态侧,也不会替方法参数提供实现或运行时校验。

3. 对接口使用 instanceof

接口没有运行时值,不能写 value instanceof SomeInterface。应检查真实类、判别字段或用类型守卫验证结构。

4. 误以为 readonly 是深层且运行时不可变

类字段的 readonly 仍然是浅层类型约束。嵌套对象可能可变,编译后的 JavaScript 也不会自动冻结字段。

5. 省略重写意图

未使用 override 时,基类改名可能让派生类静默失去覆盖关系。开启 noImplicitOverride,让编译器协助重构。

6. 为代码复用建立过深继承树

继承会把子类绑定到基类状态、生命周期和变化方向。优先依赖小接口并使用组合;只有稳定的“是一个”关系才值得继承。

小结

TypeScript 类既是运行时构造器,也是实例类型。参数属性能减少样板,publicprotectedprivate 管理编译期可见性,JavaScript #private 提供运行时私有语义,readonly 则表达初始化后的浅层不可重新赋值。用 implements 在类声明处检查结构契约,用抽象类共享稳定流程和实现,并通过 overridenoImplicitOverride 保护继承重构。默认依赖最小结构接口并优先组合,能让类、普通对象和测试替身在同一契约下自然协作。

官方资料

系列导航:目录 · 上一篇 · 下一篇