《TypeScript编程入门》6.1 类、访问修饰符与参数属性

本节讲解 TypeScript 的类相比 JavaScript 多出了什么:访问修饰符如何约束成员的可见性,参数属性怎样把构造函数的样板赋值压缩成一行,普通私有成员与井号私有字段又有什么本质差别。读完本节,你能为真实工程里的领域模型写出既安全又简洁的类,并避开严格模式下属性未初始化的常见报错,为后续学习继承与多态打好基础。

本节目标:理解 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 岁

这里有三点值得注意:

  1. 字段必须先声明。name: string; 这行只是给编译器看的类型声明,编译成 JavaScript 后会被完全擦除。
  2. 不加修饰符默认是 public。u.name 可以在任何地方读写。
  3. 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 继承、抽象类与多态 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes