本节目标:理解 TypeScript 的
class相比 JavaScript 多了什么,掌握public、private、protected、readonly四类访问修饰符的语义与编译期约束,学会用参数属性把构造函数里的样板赋值压成一行,并说清楚private与 ECMAScript 原生#私有字段的本质差别。读完本节,你能为真实工程里的领域模型写出既安全又简洁的类。
6.1 类、访问修饰符与参数属性
JavaScript 从 ES2015 开始有了 class,但它本质上是原型链的语法糖:属性可以随时被外部读写,构造函数里要把参数一个个赋给 this。TypeScript 的 class 保留了这套运行时行为,额外加了一层编译期可见性检查。理解「哪些是运行时真的存在、哪些只是编译期约束」是本节的主线。类与对象的基本概念如果你还不熟,可以先看 面向对象基础
补一补。
6.1.1 一个没有修饰符的类
先看最朴素的写法,它和 JavaScript 几乎一模一样:
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): string {
return `你好,我是 ${this.name},今年 ${this.age} 岁`;
}
}
const u = new User("小明", 18);
console.log(u.greet()); // 你好,我是 小明,今年 18 岁
这里有三点值得注意:
- 字段必须先声明。
name: string;这行只是给编译器看的类型声明,编译成 JavaScript 后会被完全擦除。 - 不加修饰符默认是
public。u.name可以在任何地方读写。 this的类型是User,所以this.name能推导出string。
如果你写 this.namee = name(拼错一个字母),TypeScript 会立刻报错:
error TS2551: Property 'namee' does not exist on type 'User'. Did you mean 'name'?
这正是类型系统在类里最先帮你挡住的一类错误:成员名拼写。
6.1.2 四类访问修饰符
TypeScript 提供三种可见性修饰符,外加一个只读修饰符。它们写在成员前面:
| 修饰符 | 类内部 | 子类 | 类外部 | 运行时是否强制 |
|---|---|---|---|---|
public(默认) | 可访问 | 可访问 | 可访问 | 否 |
protected | 可访问 | 可访问 | 不可访问 | 否(仅编译期) |
private | 可访问 | 不可访问 | 不可访问 | 否(仅编译期) |
readonly | 可读不可写 | 可读不可写 | 可读不可写 | 否(仅编译期) |
把上面的 User 改写成带修饰符的版本:
class Account {
public readonly id: string;
private balance: number;
protected ownerName: string;
constructor(id: string, ownerName: string, balance: number) {
this.id = id;
this.ownerName = ownerName;
this.balance = balance;
}
deposit(amount: number): void {
if (amount <= 0) {
throw new RangeError("存款金额必须为正数");
}
this.balance += amount; // 类内部可访问 private
}
getBalance(): number {
return this.balance;
}
}
const acc = new Account("A-001", "小明", 100);
console.log(acc.id); // A-001,readonly 可读
console.log(acc.getBalance()); // 100
acc.balance = 999; // error TS2341: Property 'balance' is private and only accessible within class 'Account'.
acc.id = "A-002"; // error TS2540: Cannot assign to 'id' because it is a read-only property.
acc.ownerName = "x"; // error TS2445: Property 'ownerName' is protected and only accessible within class 'Account' and its subclasses.
三个报错信息都值得记住,它们是日常开发里出现频率最高的类相关错误。
关键认知:这些修饰符在编译产物里消失了。 把上面的代码编译成 ES2017,private balance 只剩下一个普通的属性赋值。也就是说:
// 编译后的 JavaScript(示意)
class Account {
constructor(id, ownerName, balance) {
this.id = id;
this.ownerName = ownerName;
this.balance = balance;
}
}
运行时通过 (acc as any).balance = 999 依然能改掉它。private 是给编译器和协作者看的约束,不是安全边界。要真正的运行时私有,得用下一小节的 # 字段。
6.1.3 参数属性:消灭构造函数的样板代码
上面 Account 的构造函数里,this.id = id 这类「收参数、存字段」的赋值占了大部分篇幅。TypeScript 提供了参数属性(parameter properties)语法,把「声明字段 + 接收参数 + 赋值」三步合成一步:
class Account {
constructor(
public readonly id: string,
protected ownerName: string,
private balance: number = 0,
) {}
deposit(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
只要在构造函数的参数前加上 public、protected、private 或 readonly,TypeScript 就会自动:
- 生成同名的实例字段;
- 在构造函数体开头插入
this.xxx = xxx; - 按修饰符施加可见性约束。
上面这段和 6.1.2 里的长版本完全等价,包括 readonly 语义和默认值 = 0。注意默认值写法和普通参数一样,且参数属性必须出现在构造函数参数列表里,不能用在普通方法上。
参数属性能混用普通参数,但要注意顺序:带默认值或 ? 的普通参数之后不能再跟必填参数,这条规则和 函数签名、可选参数与默认值
里讲过的完全一致。
一个常见坑是「参数属性 + 解构」:
// 想这么写?编译器不接受
class Config {
constructor(private { host, port }: { host: string; port: number }) {}
// error TS1187: A parameter property may not be declared using a binding pattern.
// 修饰符后面必须跟一个标识符,解构模式不是标识符
}
参数属性只能作用于标识符,不能作用于解构模式,也不能作用于剩余参数。遇到需要从对象里取多个字段的场景,老老实实先解构再显式赋值:constructor({ host, port }: ConfigShape) { this.host = host; this.port = port; }。
6.1.4 getter / setter 与只读字段
readonly 表示「构造完成后不可再写」,但真实业务里常需要「外部只读、内部可控」的字段。这时用 getter 更灵活:
class Temperature {
private _celsius: number;
constructor(celsius: number) {
this._celsius = celsius;
}
// 只给 get,不给 set,对外就是只读的
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
if (value < -273.15) {
throw new RangeError("温度不能低于绝对零度");
}
this._celsius = value;
}
get fahrenheit(): number {
return this._celsius * 1.8 + 32;
}
}
const t = new Temperature(25);
console.log(t.fahrenheit); // 77
t.celsius = 30; // 走 setter,做校验
console.log(t.fahrenheit); // 86
几个要点:
get celsius()的类型由返回值推断,set celsius(value)的参数类型必须和 getter 返回类型兼容(TS 4.3 起两者类型可以不同,但赋值方向必须合法)。- 只写 getter 不写 setter,字段在外部就是只读的;但在
--strict下尝试写会报 TS2540。 - 用 getter 暴露
celsius的同时私有字段叫_celsius,这是社区最通行的命名约定。 - 有 getter/setter 的成员不能同时声明为
readonly。
一个容易踩的坑:getter 与同名属性不能共存。同一个类里既有 celsius = 0 字段又有 get celsius(),编译器会报 error TS2300: Duplicate identifier 'celsius'.。
6.1.5 static 成员
static 成员挂在类本身而不是实例上,用来放工厂方法、常量、计数器:
class IdGenerator {
private static counter = 0;
static readonly prefix = "USR";
static next(): string {
IdGenerator.counter += 1;
return `${IdGenerator.prefix}-${IdGenerator.counter.toString().padStart(4, "0")}`;
}
constructor(public readonly id: string = IdGenerator.next()) {}
}
console.log(IdGenerator.next()); // USR-0001
console.log(IdGenerator.next()); // USR-0002
const user = new IdGenerator();
console.log(user.id); // USR-0003
注意 static 成员可以被 private / readonly 修饰,private static counter 在类外部访问同样报 TS2341。static 与 private 组合是实现「类级单例计数器」的标准手法。
一个经典陷阱:静态方法里的 this 指向类构造器而不是实例,所以 static describe() { return this.tag; } 拿到的是类上的 tag。若把静态方法单独取出调用(const d = Factory.describe; d()),this 会变成 undefined 并在严格模式下抛错。需要固定 this 时用箭头函数字段,细节见 重载、this 类型与箭头函数
。
6.1.6 # 私有字段与 private 的差别
ES2022 引入了语言级的私有字段,语法是 #:
class Vault {
#secret: string;
constructor(secret: string) {
this.#secret = secret;
}
reveal(): string {
return this.#secret;
}
}
const v = new Vault("key");
v.reveal(); // "key"
(v as any).#secret; // SyntaxError:语法层面就不存在
(v as any).secret; // undefined,字段名根本不在实例上
对比表:
| 维度 | private | # 私有字段 |
|---|---|---|
| 谁在管 | TypeScript 编译器 | JavaScript 引擎 |
| 编译产物 | 普通属性,运行时可绕过 | 真正的私有槽,运行时无法访问 |
能否用 any 绕过 | 能 | 不能 |
| 子类可见性 | 子类不可见 | 子类不可见 |
与 keyof / 反射 | 会出现在 keyof 结果里 | 不出现 |
| 目标环境要求 | 无 | 需 ES2022 或降级编译 |
TypeScript 允许两者共存,但注意 # 字段不能写成参数属性(constructor(#secret: string) 不是合法语法),必须在类体里单独声明、在构造函数里显式赋值。实践建议:
- 只想约束团队协作、不涉及安全:用
private,它更灵活(比如测试里可以as any打桩)。 - 确实不希望外部碰到、或字段名不该被
keyof枚举:用#。 - 不要混着用,同一个类里两种私有风格并存会让读者困惑。
# 字段还有一个副作用:它不会出现在 Object.keys(instance) 里,序列化时自然被忽略——这既是优点也是坑,写 DTO 时要注意。
6.1.7 strict 模式下的属性初始化
在 strictPropertyInitialization(属于 strict 家族)开启时,TypeScript 要求每个非可选字段必须在构造函数里被赋值,或者给出明确的「我保证会赋值」标记:
class Bad {
name: string;
// error TS2564: Property 'name' has no initializer and is not definitely assigned in the constructor.
}
两种常见的合法解决方式:
class Good1 {
name: string = ""; // 1. 给默认值
}
class Good2 {
name!: string; // 2. 明确断言「一定会被赋值」
async init(): Promise<void> {
this.name = await fetchName(); // 由异步初始化负责
}
}
! 是非空断言(definite assignment assertion),它只是让编译器闭嘴,不会在运行时插入任何检查。用在「框架会在构造后注入」的场景(比如 ORM 实体)是合理的,用在偷懒上则会把运行时错误推迟到更难排查的地方。! 与 ?.、?? 的差别在 any·unknown·never·void 与类型断言
里做过系统对比,这里只强调一句:用了 ! 就意味着你向编译器做了承诺。
6.1.8 类在类型系统里的位置
最后澄清一个初学者最容易迷糊的点:一个类名同时是「值」和「类型」。
class Point {
constructor(public x: number, public y: number) {}
}
// 当类型用:描述实例的形状
function dist(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
// 当值用:构造实例、访问静态成员
const p = new Point(0, 0);
console.log(typeof Point); // function
而 typeof Point 才是构造器本身的类型。这个「实例类型 vs 构造器类型」的双重身份,是后面 接口实现与 mixin
里写 new (...args) => T 这类签名的前提。
另外,类实例的类型比较走的是结构化规则,而不是「必须同一个类」:
class Cat { meow() { return "喵"; } }
class Dog { meow() { return "汪"; } }
const c: Cat = new Dog(); // 合法!形状一致即可
这背后的规则与 结构化类型与两者取舍
完全一致。但要注意:一旦类里含有 private 或 protected 成员,结构化比较就会失效——两个类的私有成员必须「来自同一个声明」才算兼容。
class A { private x = 1; }
class B { private x = 1; }
let a: A = new B();
// error TS2322: Type 'B' is not assignable to type 'A'.
// Types have separate declarations of a private property 'x'.
这个特性常被用来做名义化(nominal)标记:给类加一个无用的 private 字段,就能阻止任意同形状对象冒充它。
小结
public(默认)、protected、private、readonly四类修饰符都只在编译期生效,产物里不留痕迹;private挡不住as any,#才能。- 参数属性把「声明字段 + 收参数 + 赋值」压成一行,但仅限标识符参数,且
readonly/ 默认值都可叠加。 - getter/setter 适合「对外只读、对内可写 + 校验」的场景,注意 getter 不能与同名字段共存,也不能和
readonly同用。 static成员属于类本身,private static可做类级计数器;静态方法里的this指向构造器。strictPropertyInitialization要求字段在构造函数里被赋值,!断言是「我保证」而非运行时检查。- 类名同时是值和类型;类实例走结构化比较,但含
private/protected成员时退化为名义比较,可用来做类型标记。 - 记住两句话:修饰符都是编译期约束,要运行时真私有请用
#;参数属性是消灭构造函数样板的利器,但只能用于标识符参数。 - 类真正的威力在于「复用」:把公共行为放到父类,把差异留给子类。这正是下一节 继承、抽象类与多态 的主题。
阅读导航:上一节:5.3 结构化类型与两者取舍 · 下一节:6.2 继承、抽象类与多态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。