TypeScript 因其强大的类型系统,成为了许多习惯使用静态类型语言(如 C# 和 Java)的开发者的热门选择。它能够提供更智能的代码补全、更早期的错误检测,以及更清晰的代码通信。然而,直接从传统 OOP 语言转向 TypeScript 的开发者,常常会遇到一些思维上的陷阱。理解 JavaScript(TypeScript 的运行时)与传统 OOP 语言的根本差异,是编写优雅且高效的 TypeScript 代码的关键。
面向 Java/C# 开发者的学习路径
如果你已经熟悉 JavaScript,但主要背景是 Java 或 C#,本文旨在帮助你澄清一些常见的误解。TypeScript 在类型建模上的思路与后两者有显著不同。
如果你是 Java 或 C# 程序员,并且正在初学 JavaScript,建议你先了解一些无类型的原生 JavaScript 知识。因为 TypeScript 不会改变代码的运行时行为,掌握 JavaScript 的工作原理是写出正确代码的基础。请记住,TypeScript 的运行时就是 JavaScript,因此任何关于 JavaScript 运行时行为(如类型转换、DOM 操作等)的资源对 TypeScript 同样有效。
重新审视“类”的概念
C# 和 Java 是典型的“强制面向对象”语言,class 是组织代码和封装数据的核心单元。然而,并非所有场景都适合用这种严格的层级结构来建模。
在 JavaScript 中,函数和数据享有更大的自由。函数可以独立存在(“自由函数”),数据也可以在不依赖预定义 class 的情况下传递。这种模式在 JavaScript 社区中非常普遍且富有表现力。因此,来自 C#/Java 的一些模式,如单例模式或静态类,在 TypeScript/JavaScript 中往往不是必需的。
当然,如果你倾向于使用类,TypeScript 也提供了强大的支持,包括接口实现、继承和静态方法等,这非常适合解决某些特定领域的问题。我们将在后续的指南中详细探讨类的用法。
深入理解 TypeScript 的类型系统
TypeScript 的类型理念与 C# 或 Java 存在本质区别,主要体现在以下几个方面。
名义化 (Nominal) 与具体化 (Reified) 类型系统
在 C# 或 Java 中,任何值在运行时都有一个确切的类型(null、原始类型或某个已知的类类型)。你可以通过 GetType() 或 getClass() 等方法在运行时查询它。类型之间的关系基于显式的声明(如继承或实现接口),即使两个类结构完全相同,只要名称不同,也不能互换使用。这构成了一个 “具体化的名义类型系统”:类型信息在运行时存在,且兼容性由名称决定。
将类型视为值的集合
在 TypeScript 中,更有效的思维方式是:将类型视为一组具有共同特征的值的集合。因为类型是集合,所以一个值可以同时属于多个集合(类型)。
一旦接受了“类型即集合”的观点,许多操作就变得直观。例如,如何描述一个值可以是 string 或 number?答案就是这两个集合的并集:string | number。TypeScript 提供的联合类型、交叉类型等特性,都可以用集合论来自然地理解。
基于结构的类型擦除
在 TypeScript 中,对象并不绑定于某个唯一的命名类型。只要一个对象在结构上满足某个接口的要求,就可以在需要该接口的地方使用它,而无需显式声明“实现”关系。
interface Pointlike {
x: number;
y: number;
}
interface Named {
name: string;
}
function logPoint(point: Pointlike) {
console.log("x = " + point.x + ", y = " + point.y);
}
function logName(x: Named) {
console.log("Hello, " + x.name);
}
const obj = {
x: 0,
y: 0,
name: "Origin",
};
logPoint(obj); // 正确
logName(obj); // 正确
TypeScript 使用 结构化类型系统 :类型兼容性由实际拥有的属性决定,而非声明。同时,它的类型系统是 非具体化 的:像 Pointlike 这样的接口类型在运行时完全不存在,编译后会被擦除。用集合的观点看,obj 同时是 Pointlike 集合和 Named 集合的成员。
结构化类型带来的独特现象
对于 OOP 开发者来说,结构化类型系统有两个地方可能出乎意料。
空类型
class Empty {}
function fn(arg: Empty) {
// do something?
}
// 没有错误!但 `{ k: 10 }` 并不是一个 `Empty` 实例?
fn({ k: 10 });
TypeScript 检查传入的 { k: 10 } 是否满足 Empty 的结构。由于 Empty 类没有任何属性,任何对象(只要不是 null 或 undefined)都包含了 Empty 所要求的“所有属性”(即没有属性),因此赋值是合法的。在结构类型中,这被视为一种子类型关系。
相同结构
class Car {
drive() {
// 踩油门
}
}
class Golfer {
drive() {
// 击球
}
}
// 没有错误?
let w: Car = new Golfer();
这段代码不会报错,因为两个类的结构(都有一个无参数的 drive 方法)是兼容的。尽管语义迥异,但在结构类型系统看来它们是可互换的。在实际开发中,这种结构完全相同但意图完全不同的类非常少见。
关于反射
OOP 开发者习惯于在运行时查询类型信息,即使是泛型参数:
// C#
static void LogType<T>() {
Console.WriteLine(typeof(T).Name);
}
但在 TypeScript 中,类型信息在编译后被完全擦除,因此无法在运行时获取泛型参数的具体实例化信息。JavaScript 提供的 typeof 和 instanceof 等操作符,作用的是运行时值的本身,例如 typeof (new Car()) 返回的是 "object",而非任何与 Car 类型相关的信息。
核心理念总结
对于来自 Java 或 C# 的开发者,学习 TypeScript 最关键的是转变以下思维:
- 从“名义”到“结构”:类型兼容性不再依赖于类名或继承关系,而是取决于对象是否在结构上匹配(“鸭子类型”)。
- 编译时类型,运行时擦除:接口、类型别名、泛型参数等仅用于编译阶段的静态检查,不会保留到运行时。
- 拥抱函数与数据的自由:鼓励使用函数作为一等公民来组合逻辑,而非将所有功能强制封装进类中。
- 用集合思维理解类型:将类型视为值的集合,联合类型 (
|)、交叉类型 (&) 等操作会变得非常直观。
理解这些根本差异,能帮助您避免常见的误区,并充分利用 TypeScript 强大的类型表达能力进行有效的类型建模和软件开发。