在 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 会影响兼容性」的设计。
五、常见坑汇总
- 把
interface当值用:new User()或User.name都不行,接口编译后不存在。要运行时对象就用class或普通对象。 readonly和private都只是编译期约束:运行时属性可读可写,真正的运行时私有要用#字段。implements不检查构造函数参数和静态成员:接口只描述实例形状。- 派生类字段在
super()之后才初始化:父类构造函数里调用被覆盖的方法会读到undefined。 - 空接口
{}不是「空对象」:它表示「任何非null/undefined的值」,字符串、数字都能赋给它。想表达空对象用Record<string, never>。 - 多余属性检查只对对象字面量生效:直接赋字面量会报错,先存进变量再赋值就不报了——这不是漏洞,而是结构化类型的必然结果。
- 索引签名会放宽所有属性名检查:写上
[key: string]: T之后,属性名拼错不再报错,且所有具名属性的类型必须能赋给T。 - 静态成员不能用类泛型参数:
T属于实例,静态成员属于类。 - 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
评论区