侧边栏壁纸
博主头像
Setyg的博客

当秋风染红落叶的时候,就是新生~

  • 累计撰写 86 篇文章
  • 累计创建 66 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

TypeScript 中类与接口的定义与实践

Setyg
2026-09-21 / 0 评论 / 0 点赞 / 4 阅读 / 0 字

在 TypeScript 里描述「一个对象长什么样」有两条路:interface 和 class。两者语法有重合,定位却完全不同——一个只描述结构、编译后消失;一个既是类型也是值、编译后生成真实的构造函数。混着用是很多类型设计走歪的起点。本文先把两者的定义写法过一遍,再说清楚什么时候该用哪个。

一、先分清定位

对比项 interface class
本质 纯类型,只描述结构 既是类型,也是值(运行时真实存在)
编译产物 完全消失 生成构造函数与原型方法
能否 new 不能 能(抽象类除外)
能否用作值 不能,let u: User 可以,User 本身不是值 可以,既能当类型也能当值用
能否被继承 extends 多个接口 extends 单个父类
声明合并 支持,同名接口自动合并 不支持
典型用途 接口返回值、组件 props、函数参数、任何纯数据结构 需要实例、行为、继承、instanceof 的场景

一句话记法:interface 描述「它长什么样」,class 描述「它是什么、能做什么」。

二、接口(interface)

1. 基本定义与属性修饰

interface User {
  id: number;                    // 必填属性
  name: string;
  age?: number;                  // 可选属性
  readonly email: string;        // 只读属性
  [key: string]: unknown;        // 索引签名:允许任意其他属性
}

const u: User = { id: 1, name: 'Alice', email: 'a@example.com' };

u.name = 'Bob';        // ✅
// u.email = 'b@x.com'; // ❌ 只读,编译期报错
// u.id = 2;            // ✅ id 没标 readonly,可改
u.age = 30;            // ✅ 可选属性可以不写,也可以后补

三个修饰符的性质要知道:

  • 可选属性 ?:类型实际变成 number | undefined,用之前要判空(开了 strictNullChecks 的话)。
  • readonly 只活在编译期:编译产物里没有任何保护,运行时照样能改。真要运行时不可变得靠 Object.freeze()。
  • 索引签名 [key: string]: T 会放宽整个对象的检查:一旦写上它,拼错属性名不再报错,因为任何字符串键都合法。上面写 unknown 是安全的做法——若写成 [key: string]: string,则所有具名属性都必须是 string,id: number 会直接报错。

2. 描述函数与构造函数

// 可调用签名:描述"这个值本身可以被调用"
interface SearchFn {
  (source: string, sub: string): boolean;
}

const mySearch: SearchFn = (src, sub) => src.includes(sub);

// 构造签名:描述"这个值可以被 new"
interface PointConstructor {
  new (x: number, y: number): { x: number; y: number };
}

declare const P: PointConstructor;
const p = new P(1, 2);

另外,方法的两种写法在类型检查上有细微差别,这是容易被忽略的点:

interface A {
  fn(x: string): void;      // 方法语法
}

interface B {
  fn: (x: string) => void;   // 属性语法(函数类型)
}

开启 strictFunctionTypes 后,方法语法的参数是「双向兼容」的(bivariant),属性语法是严格逆变(contravariant)。日常写代码感知不到,但在做类型收窄、封装工具类型时会有差别。一般写接口用方法语法即可。

3. 继承与声明合并

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

// 接口可以多继承
interface Person extends Named, Aged {
  email: string;
}

const p: Person = { name: 'Alice', age: 30, email: 'a@x.com' };

interface 还有一个 type 没有的能力——声明合并:同名的接口会自动合并成一个,这在给第三方库打补丁(配合 declare module)时非常有用。

interface Window {
  myGlobal: string;
}

// 另一个文件里再写一次,不会冲突,而是合并
interface Window {
  myOtherGlobal: number;
}

// 此时 window 上两个属性都存在

4. interface 还是 type

能力 interface type
描述对象结构✅✅
声明合并✅❌ 重复声明会报错
联合类型 / 交叉类型❌✅ A | B、A & B
元组、字面量、原始类型别名❌✅ type Id = string | number
条件类型、映射类型❌✅
报错信息可读性较好(显示接口名)复杂类型下可能展开一大串

通行做法:描述对象结构用 interface,其余(联合、交叉、工具类型、别名)用 type。两者能力重叠的部分按团队习惯统一即可,别在同一项目里一会儿 interface 一会儿 type 描述同一种东西。

三、类(class)

1. 基本定义与访问修饰符

class Person {
  public name: string;              // 默认就是 public,可省略
  private age: number;              // 仅类内部可访问
  protected id: number;             // 类内部 + 子类可访问
  readonly createdAt: Date;         // 只读
  static species = 'human';         // 静态成员,挂在类上而非实例上

  constructor(name: string, age: number, id: number) {
    this.name = name;
    this.age = age;
    this.id = id;
    this.createdAt = new Date();
  }

  greet(): void {
    console.log(`Hi, I'm ${this.name}`);
  }
}

const person = new Person('Alice', 30, 1);
person.greet();
console.log(Person.species);   // 'human'
// person.age;                 // ❌ private,编译期报错

TypeScript 的访问修饰符是编译期约束,编译产物里它们全部消失,属性在运行时依然是普通属性。真正的运行时私有要用 # 前缀(ES2022 的私有字段):

class Account {
  private balance = 0;    // 编译期私有:运行时 obj['balance'] 仍能读写
  #secret = 'token';      // 运行时私有:JS 引擎层面就访问不到
}

2. 参数属性:省掉样板赋值

// 啰嗦写法
class A {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
}

// 参数属性:构造函数参数上直接写修饰符,TS 会自动声明并赋值
class B {
  constructor(private name: string, public age: number) {}
}

这是 TypeScript 独有的语法糖,编译后会展开成上面的赋值语句。注意它只能用在构造函数参数上。

3. 严格模式下的属性初始化

开启 strictPropertyInitialization(属于 strict)后,类属性要么有初始值,要么在构造函数里赋值,否则报错。三种合法的解决方式:

class Config {
  name: string = 'default';      // 方式一:给初始值
  port: number;                  // 方式二:构造函数里赋值
  host!: string;                 // 方式三:确定赋值断言 !,表示"我保证会用之前赋上"
  debug?: boolean;               // 方式四:声明为可选,类型里带 undefined

  constructor(port: number) {
    this.port = port;
  }
}

! 是单方面向编译器保证,它不会生成任何检查。用错地方(比如实际没赋值就去读)拿到的就是 undefined,和 as 一样属于「关掉检查」的手段,能不用就不用。

4. getter / setter

class Temperature {
  private _celsius = 0;

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error('低于绝对零度');
    }
    this._celsius = value;
  }

  get fahrenheit(): number {
    return this._celsius * 1.8 + 32;
  }
}

const t = new Temperature();
t.celsius = 25;                 // 走 setter
console.log(t.celsius, t.fahrenheit);

访问器在类型上表现为普通属性,所以接口里也能声明(TypeScript 4.3 起支持 get/set 语法):

interface Thing {
  get size(): number;
  set size(value: number);
}

5. 实现接口(implements)

interface Serializable {
  serialize(): string;
}

interface Comparable {
  compareTo(other: unknown): number;
}

// 一个类可以实现多个接口
class Record implements Serializable, Comparable {
  constructor(private data: Record<string, unknown>) {}

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

  compareTo(other: Record): number {
    return this.serialize().localeCompare(other.serialize());
  }
}

implements 有两个必须知道的边界:

  • 只检查实例侧,不检查静态成员:接口里写的 static 语义(构造签名、静态属性)implements 是不管的。要约束「构造函数长什么样」,得用构造签名 + 工厂函数或泛型约束 { new (...): T }。
  • 不检查构造函数参数:接口只描述实例的形状,new 时传什么参数它管不着。

6. 继承(extends)与抽象类

abstract class Shape {
  abstract area(): number;          // 抽象方法:子类必须实现

  describe(): string {              // 普通方法:子类可直接继承
    return `面积是 ${this.area()}`;
  }
}

class Circle extends Shape {
  constructor(private r: number) {
    super();                        // 必须先调用 super
  }

  area(): number {
    return Math.PI * this.r ** 2;
  }
}

// new Shape();   // ❌ 抽象类不能实例化
const c = new Circle(2);
console.log(c.describe());

抽象类 vs 接口:两者都能用来定义「一组子类型必须满足的契约」,区别在于抽象类可以带实现(共享代码)且只能单继承,接口不能带实现但可以多实现。想共享代码用抽象类,只想约束形状用接口。

7. 派生类的字段初始化顺序(高频坑)

class Base {
  constructor() {
    this.init();          // 父类构造函数里调用了会被覆盖的方法
  }
  init(): void {}
}

class Derived extends Base {
  name = 'derived';       // 类字段初始化发生在 super() 之后

  init(): void {
    console.log(this.name);   // ⚠️ 打印 undefined,此时字段还没初始化
  }
}

new Derived();

执行顺序是:父类构造函数 → 子类字段初始化 → 子类构造函数体。所以父类构造函数里调用的方法,如果被子类覆盖且用了子类自己的字段,拿到的会是 undefined。这是 JavaScript 类继承本身的行为,不是 TypeScript 的问题,但 TS 的类型系统也拦不住——它认为 this.name 就是 string。

规避方式:不要在构造函数里调用可被覆盖的方法,改成在子类构造函数尾部显式调用初始化方法。

8. 泛型类与静态成员

class Box<T> {
  private items: T[] = [];

  add(item: T): this {
    this.items.push(item);
    return this;
  }

  get first(): T | undefined {
    return this.items[0];
  }

  // static 成员不能引用类的类型参数 T
  // static defaultValue: T;   // ❌ 报错
}

const box = new Box<number>().add(1).add(2);
// 实例化时类型参数也可以由参数推断
const strBox = new Box().add('hello');   // Box<string>

静态成员不能使用类的泛型参数,因为静态成员属于类本身而不是某个实例,而 T 只有在实例化时才确定。

9. 接口继承类

反过来也成立——接口可以 extends 一个类,用来提取这个类「实例侧」的形状(包括 private、protected 成员,此时这个接口就只能由该类的子类实现了):

class Control {
  private state = 0;
}

interface SelectableControl extends Control {
  select(): void;
}

// 只有 Control 的子类才能实现它,因为外部无法提供 state
class Button extends Control implements SelectableControl {
  select(): void {}
}

这个用法比较少见,但读老代码时会遇到。

四、关键差异:结构化类型

这是 TypeScript 和 Java / C# 最根本的区别——TypeScript 是结构化类型系统(structural typing),只看形状是否匹配,不要求显式声明 implements:

interface Point {
  x: number;
  y: number;
}

class MyPoint {
  x = 0;
  y = 0;
}

const p: Point = new MyPoint();   // ✅ 没有 implements,照样通过

好处是灵活、不需要为了类型而改继承关系;代价是容易因为「碰巧形状一样」而误通过。有一个重要的例外:

class A {
  private secret = 1;
}

class B {
  private secret = 1;
}

let a: A = new A();
// a = new B();   // ❌ 报错

一旦类里有 private 或 protected 成员,比较就退化成名义类型(nominal typing)——必须来自同一个类声明才算兼容。这是类型系统里少见的「private 会影响兼容性」的设计。

五、常见坑汇总

  1. 把 interface 当值用:new User() 或 User.name 都不行,接口编译后不存在。要运行时对象就用 class 或普通对象。
  2. readonly 和 private 都只是编译期约束:运行时属性可读可写,真正的运行时私有要用 # 字段。
  3. implements 不检查构造函数参数和静态成员:接口只描述实例形状。
  4. 派生类字段在 super() 之后才初始化:父类构造函数里调用被覆盖的方法会读到 undefined。
  5. 空接口 {} 不是「空对象」:它表示「任何非 null / undefined 的值」,字符串、数字都能赋给它。想表达空对象用 Record<string, never>。
  6. 多余属性检查只对对象字面量生效:直接赋字面量会报错,先存进变量再赋值就不报了——这不是漏洞,而是结构化类型的必然结果。
  7. 索引签名会放宽所有属性名检查:写上 [key: string]: T 之后,属性名拼错不再报错,且所有具名属性的类型必须能赋给 T。
  8. 静态成员不能用类泛型参数:T 属于实例,静态成员属于类。
  9. class 会生成运行时代码,interface 不会:打包体积敏感或要做类型擦除时,纯数据结构优先用 interface / type。

六、速查卡

// ── 接口 ────────────────────────────────────
interface User {
  id: number;
  name: string;
  age?: number;                     // 可选
  readonly email: string;           // 只读(编译期)
  [key: string]: unknown;           // 索引签名
}

interface Fn { (x: string): boolean }        // 可调用签名
interface Ctor { new (): User }              // 构造签名
interface C extends A, B {}                  // 多继承
interface Window { g: string }               // 同名自动合并

// ── 类 ──────────────────────────────────────
class Person {                               // 需要契约时写 implements User
  static species = 'human';                  // 静态成员
  private age = 0;                           // 编译期私有
  #secret = 'x';                             // 运行时私有
  protected id = 1;
  readonly createdAt = new Date();
  host!: string;                             // 确定赋值断言

  constructor(public name: string, id: number) {   // 参数属性
    this.id = id;
  }

  get label(): string { return this.name; }        // 访问器
  set label(v: string) { this.name = v; }

  greet(): void {}
}

abstract class Shape {                       // 抽象类
  abstract area(): number;                   // 子类必须实现
  describe(): string { return 'shape' }      // 子类可继承
}

class Box<T> {                               // 泛型类
  items: T[] = [];
  // static def: T;                         // ❌ 静态成员不能用 T
}
// ── 选择依据 ────────────────────────────────
// 只描述数据结构(接口返回、props、参数)  → interface / type
// 需要实例、行为、继承、instanceof        → class
// 只约束形状、可能被多个类满足            → interface
// 要共享实现代码                          → abstract class
// 联合、交叉、工具类型                    → type
0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区