TypeScript 类设计:修饰符、抽象类与接口契约
TypeScript 类设计:修饰符、抽象类与接口契约
系列导航:TypeScript 7 现代开发指南
上一篇:类型组合
下一篇:泛型与类型运算
JavaScript 的 class 提供构造器、继承和真正的私有字段,TypeScript 7.0.2 在此基础上增加参数属性、可见性修饰符、readonly、implements、抽象类和 override 检查。类最适合封装具有状态、行为和生命周期的对象;接口则描述调用方真正需要的能力。掌握两者的边界,才能避免把类型契约误当成运行时保护,也能避免为了复用几行代码建立脆弱的继承层级。
一、类同时带来运行时值和实例类型
类声明既会生成运行时可调用的构造器,也会创建实例侧类型:
class Counter {
count = 0
increment(step = 1): number {
this.count += step
return this.count
}
}
const counter: Counter = new Counter()
counter.increment()
与 interface、type 不同,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
}
}
给构造器参数添加 public、protected、private 或 readonly 后,参数会同时成为实例属性:
class Article {
constructor(
public readonly id: string,
public title: string,
) {}
rename(title: string) {
this.title = title
}
}
参数属性适合简单的数据承载类,但它会在构造器签名中同时声明字段。参数很多、初始化需要校验,或希望字段定义一眼可见时,显式声明往往更易读。
三、public、protected 与 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 后通常仍是常规属性,不能把它当作抵御恶意运行时代码的安全边界。
private 和 protected 还会影响类类型之间的结构兼容:若两个类各自声明了私有或受保护成员,即使公开结构相同,也通常要求这些成员源自同一个类声明。这个规则让带私有状态的类具有一部分名义类型特征。
四、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 |
— | ✓ |
抽象类适合确实存在“是一个”关系、子类可安全替代基类,并且共享实现具有稳定语义的场景。仅仅因为两个类碰巧有几行相同代码,不足以建立继承关系。
八、用 override 和 noImplicitOverride 防止静默失配
派生类重写基类成员时,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 private、protected 或 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 类既是运行时构造器,也是实例类型。参数属性能减少样板,public、protected、private 管理编译期可见性,JavaScript #private 提供运行时私有语义,readonly 则表达初始化后的浅层不可重新赋值。用 implements 在类声明处检查结构契约,用抽象类共享稳定流程和实现,并通过 override 与 noImplicitOverride 保护继承重构。默认依赖最小结构接口并优先组合,能让类、普通对象和测试替身在同一契约下自然协作。
官方资料
- Classes
- Class Heritage:
implements与extends - Member Visibility
override与noImplicitOverride- Type Compatibility
- MDN:Private Elements