在TypeScript中,当我们用class Foo {}定义一个类时,编译器实际上同时生成了两个类型:一个是实例类型,描述new Foo()产生的对象具备哪些属性和方法;另一个是静态类型,描述Foo这个构造函数本身以及挂在它上面的静态成员。类型兼容性检查在两侧遵循不同的路径,理解这种分离机制,是从入门TypeScript迈向熟练设计类库接口的关键一步。

一个类,两个类型:实例侧与静态侧的边界
先看一段最直观的代码。假设我们定义一个简单的类,同时包含实例成员和静态成员:
class Point {
static origin = new Point(0, 0);
constructor(public x: number, public y: number) {}
distance(): number {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
// 实例侧:描述new出来的对象
const p: Point = new Point(1, 2);
// 静态侧:描述构造函数本身
const Ctor: typeof Point = Point;
const p2 = new Ctor(3, 4);这里Point作为类型注解时,指代的是实例侧类型,因此distance方法可以通过实例访问,但origin不行。而typeof Point取到的是类声明表达式的类型,也就是静态侧,它包含构造签名为new (x: number, y: number) => Point,以及静态属性origin。用一句话概括:类名写在类型位置时取实例侧,套上typeof后取静态侧。
这种分离带来一个直接的推论:接口默认只作用于实例侧。如果你写出下面这样的接口并尝试把类赋值进去,会直接报错:
interface PointLike {
x: number;
y: number;
}
const wrong: PointLike = Point; // 报错:缺少x、y属性
const right: PointLike = new Point(1, 2); // 正确报错的原因是,Point这个值本身是构造函数,它的类型里根本没有x和y,这两个属性只有实例才有。类型系统不会自动帮你从实例侧去推断兼容性,两侧的检查是严格隔离的。
构造签名:让接口约束类的静态侧
那如果我们的需求恰恰是约束类本身呢?比如实现一个类工厂函数,要求传入的类必须能用new调用并产出某个形状的对象。此时需要使用带new调用的构造签名:
interface PointConstructor {
new (x: number, y: number): { x: number; y: number };
}
function createPoint(Ctor: PointConstructor, x: number, y: number) {
return new Ctor(x, y);
}
const pt = createPoint(Point, 10, 20); // 合法判定的关键在于:Point赋值给PointConstructor时,编译器看的是静态侧。构造函数的参数列表必须兼容目标签名(参数个数可以少不能多,参数类型要能接收),返回的实例类型必须满足签名中声明的返回类型。而distance这类实例方法在这次兼容性检查中完全不参与,这就是静态侧检查的典型特征。
更进一步,静态成员也会纳入静态侧的兼容性检查。看一个混合了静态属性的例子:
interface HasOrigin {
new (x: number, y: number): Point;
origin: Point;
}
const C: HasOrigin = Point; // 合法,因为Point有静态origin
class NoOrigin {
constructor(public x: number, public y: number) {}
}
const bad: HasOrigin = NoOrigin; // 报错:缺少静态属性origin可以看到,接口里除了构造签名之外,还可以声明普通属性。当这个接口被用于约束类时,这些普通属性会去匹配类的static成员,而不是实例成员。很多初学者在这里栽跟头,以为接口里的属性永远对应实例属性,其实取决于接口里是否存在构造签名:没有构造签名时接口只能约束实例,有构造签名时接口就成了静态侧的描述。
常见实战场景:泛型工厂与混合类
理解了两侧分离后,很多经典写法就能看懂了。第一个场景是泛型工厂函数,我们希望函数既能接收任意构造函数,又能保留返回值的具体类型,惯用写法是new (...args: any[]) => T:
function createInstance<T>(
Ctor: new (...args: any[]) => T,
...args: any[]
): T {
return new Ctor(...args);
}
class Car {
constructor(public brand: string) {}
drive() { console.log(this.brand); }
}
const car = createInstance(Car, 'Toyota');
car.drive(); // car被推断为Car类型这里Ctor的参数类型是一个带泛型返回的构造签名,TypeScript在调用时会把T推断为Car,于是car拥有完整的实例侧类型提示。注意new (...args: any[])描述的是静态侧,而返回值T描述的是实例侧,一个签名同时打通两侧,这正是类型兼容性设计的精妙之处。
另一个场景是抽象类与接口的配合。抽象类不能被直接实例化,所以给它的静态侧写签名时要小心返回类型不能是抽象类本身:
abstract class Shape {
abstract area(): number;
static create<T extends Shape>(this: new () => T): T {
return new this();
}
}
class Circle extends Shape {
area() { return Math.PI * 1; }
}
const c = Circle.create(); // 推断为Circle注意this参数的技巧:在静态方法中声明this: new () => T,等于要求调用者必须是能产出T实例的构造函数,这样new this()就合法了,而且T会被推断为实际调用者(这里是Circle)的实例类型。这是静态侧与实例侧通过多态参数联动的典型手法。
判定规则总结与避坑建议
把两侧的兼容性规则归纳成一张表,便于查阅:
| 检查场景 | 参与比较的内容 | 典型错误 |
|---|---|---|
| 实例侧赋值(对象给对象) | 实例属性、实例方法、原型链上的成员 | 缺少属性、方法签名不兼容 |
| 静态侧赋值(类给构造签名) | 构造函数参数与返回实例类型、static成员 | 缺少静态属性、构造参数不匹配 |
| 类实现接口(implements) | 仅实例侧成员 | 误以为能校验静态成员 |
有几条实践经验值得记牢。第一,implements永远只检查实例侧,想约束静态成员必须改用带构造签名的接口并在赋值时检查。第二,typeof 类名是提取静态侧类型的最直接手段,写依赖注入容器或者注册表时经常需要它来保存构造函数引用。第三,当类具有私有或受保护成员时,兼容性规则会收紧:只有同一声明来源的类才被视为结构兼容,这一点在两侧都成立,也是结构化类型系统中为数不多的名义化特征。
最后提醒一点,private和protected构造函数会影响静态侧的兼容性。如果一个类的构造函数是私有的(常见于单例模式),那么外部接口的new签名就无法与它兼容,外部代码拿不到可用的构造类型。这种情况下需要通过静态工厂方法暴露创建入口,同时用ReturnType<typeof getInstance>之类的工具类型获取实例侧类型。掌握静态侧与实例侧的分离思维后,再去阅读TypeScript标准库中Array、Error等内置类型的声明文件,会有豁然开朗的感觉。
修改时间:2026-09-08 10:59:16