Dark Dwarf Blog background

TypeScript 类型系统

TypeScript 类型系统

TypeScript 的核心是其类型系统,它为 JavaScript 带来了静态类型检查的能力。

1. 静态类型与弱类型

TypeScript 是静态类型弱类型的。

a.a. 静态类型

静态类型 (Static Typing) 意味着变量、函数参数和返回值的类型在编译时就已经确定。这与 JavaScript 在运行时才进行类型检查的动态类型机制形成鲜明对比。这种类型具有如下好处:

  1. 错误提前暴露: 静态类型检查能在编码阶段(通过 IDE)或编译阶段(通过 tsc 命令)就发现类型不匹配的错误,而不是等到代码在生产环境中运行时才报错。
// a.ts
let foo: number = 1;

// 编译时就会立即报错:
// Error: Property 'split' does not exist on type 'number'.
foo.split(" ");
  1. 类型擦除: TypeScript 的类型检查仅存在于编译时。当 tsc 将 TypeScript 代码编译成 JavaScript 后,所有的类型注解都会被“擦除”,生成的 JavaScript 代码中不包含任何类型信息。这意味着 TypeScript 的类型系统对运行时性能几乎没有影响
// 编译后的 a.js
var foo = 1;
foo.split(" "); // 这个错误在运行时依然会发生,但 TS 的目标是让我们在编译时就避免它

b.b. 弱类型

弱类型 (Weak Typing) 指的是语言在运算时允许进行隐式类型转换。TypeScript 为了完全兼容 JavaScript 的运行时行为,保留了这一特性。

// 无论在 JS 还是 TS 中,这行代码都不会报错
// 数字 1 会被隐式转换为字符串 '1'
console.log(1 + "1"); // 输出 '11'

通过启用严格的编译选项(如 strict: true)和使用 ESLint 规则(如 @typescript-eslint/restrict-plus-operands),我们可以禁止这类可能导致意外结果的隐式转换,从而让 TypeScript 表现得更像一门“强类型”语言。

2. 类型推论

类型推论 (Type Inference) 是 TypeScript 提高开发效率的关键特性。在很多情况下,我们无需手动编写类型注解,TypeScript 编译器会自动推断出变量的类型。

a.a. 工作方式

当一个变量在声明时被初始化,TypeScript 会根据初始值来推断其类型。

// 无需写 let name: string
let name = "Alice"; // TypeScript 推断 name 的类型是 string

// 无需写 let age: number
let age = 30; // TypeScript 推断 age 的类型是 number

// 此时若尝试赋予不同类型的值,将会报错
// Error: Type 'number' is not assignable to type 'string'.
name = 123;

b.b. 上下文类型

TypeScript 也会根据代码的上下文来推断类型。这在处理回调函数或事件处理器时尤其有用。

// TypeScript 知道 onmousedown 的类型是 (this: GlobalEventHandlers, ev: MouseEvent) => any
// 因此它能推断出 mouseEvent 的类型是 MouseEvent
window.onmousedown = (mouseEvent) => {
  // 我们可以直接访问 MouseEvent 上的属性,并获得智能提示
  console.log(mouseEvent.button);
};

c.c. 显式注解使用场景

尽管类型推论很强大,但在某些情况下,我们仍需或最好使用显式类型注解:

  1. 声明变量但未初始化: let x: number;
  2. 函数参数: 函数参数的类型通常无法被推断,必须显式指定。
  3. 复杂的对象或返回类型: 当函数返回一个复杂结构时,显式注解能让编译器更好地检查实现是否正确。
  4. 希望指定一个更宽泛的类型: 当推断的类型过于具体时,我们可以用显式注解来拓宽它。
// 推断类型为 { name: string; age: number; }
let person = { name: "Alice", age: 30 };

// 我们可以显式指定一个更宽泛的类型,允许拥有额外的属性
let personWithAddress: { name: string; age: number; [key: string]: any } =
  person;
personWithAddress.address = "123 Main St"; // OK

3. 类型兼容性:结构化类型系统

TypeScript 的类型兼容性判断是其类型系统的核心。它采用的是结构化类型系统(Structural Type System),有时也被称为“鸭子类型(Duck Typing)”。

“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是一只鸭子。”

这意味着,TypeScript 在判断类型兼容性时,关心的是值的结构或形状,而不是其显式的名称或 class 声明。

a.a. 对象类型的兼容性

只要一个对象拥有目标类型所要求的所有属性(且属性类型兼容),它就是兼容的。

interface Named {
  name: string;
}

class Person {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

let p: Named;
// OK, Person 实例拥有一个 string 类型的 name 属性,满足 Named 接口的要求
// Person 的结构包含了 Named 的结构
p = new Person("Alice", 30);

源类型(Person)可以拥有比目标类型(Named)更多的属性,这被称为“多余属性是允许的”。

b.b. 函数类型的兼容性

函数类型的兼容性判断相对复杂,主要涉及参数和返回值。

i.i. 函数参数

在比较函数参数时,规则是:参数可以少,但不能多;参数类型可以更宽泛,但不能更具体

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

// y 赋值给 x 是 OK 的
// 因为 x 的调用者只会传入一个 number,y 的实现能够处理这种情况(只是忽略了第二个参数)
x = y; // OK

// x 赋值给 y 是 Error 的
// 因为 y 的调用者可能会传入两个参数,而 x 的实现只能处理一个
// y = x; // Error

对于参数类型,假设 DogAnimal 的子类,那么:

  • 一个接受 Animal 的函数可以被赋值给一个要求 Dog 的函数。
  • (animal: Animal) => void 可以赋值给 (dog: Dog) => void。这是因为,如果一个函数能处理所有 Animal,它自然也能处理 Dog

ii.ii.函数返回值

在比较函数返回值时,规则是:返回值的类型可以更具体,但不能更宽泛

interface Animal {
  name: string;
}
interface Dog extends Animal {
  breed: string;
}

let f = (): Animal => ({ name: "animal" });
let g = (): Dog => ({ name: "dog", breed: "poodle" });

// g 赋值给 f 是 OK 的
// 因为 g 返回的是 Dog,它必然满足 Animal 的结构要求
f = g; // OK

// f 赋值给 g 是 Error 的
// 因为 f 只保证返回一个 Animal,它不一定有 g 所需的 breed 属性
// g = f; // Error

这个强大的类型系统,结合类型推论和结构化类型,使得 TypeScript 能够在不牺牲太多灵活性的前提下,提供强大的静态分析能力,从而构建出更健壮、更易于维护的大型应用程序。

4. 类型别名

类型别名 (Type Aliases) 使用 type 关键字,可以为任何类型起一个新名字。它不会创建新类型,只是创建一个引用。

type Point = {
  x: number;
  y: number;
};

type ID = string | number;

// 类型别名可以用于联合类型
type UserID = ID;

类型别名常用于简化复杂的类型定义,例如联合类型、元组或复杂的对象结构,以提高代码的可读性。

5. 类型断言

类型断言 (Type Assertion) 是一种向编译器提供类型信息的强硬方式,相当于告诉编译器:“相信我,我知道这个值的确切类型”。

a.a. 基本语法

TypeScript 提供两种语法,但推荐使用 as 语法,因为它在 TSX/JSX 文件中不会引起歧义。

let someValue: unknown = "this is a string";

// 1. as 语法 (推荐)
let strLength: number = (someValue as string).length;

// 2. 尖括号语法
let strLength2: number = (<string>someValue).length;

重要:类型断言只在编译时起作用,它不会执行任何运行时的类型检查或数据转换。它仅仅是给编译器的一个指令。

b.b. 类型守卫

在处理联合类型时,直接使用断言来访问特定类型的属性是有风险的,因为它可能隐藏运行时的错误。

interface Fish {
  swim(): void;
}
interface Bird {
  fly(): void;
}

function move(animal: Fish | Bird) {
  // 风险操作:如果 animal 实际上是 Bird,运行时会报错
  (animal as Fish).swim();
}

一个更安全、更推荐的做法是使用类型守卫 (Type Guards)。类型守卫是在运行时执行检查的表达式,用于确保变量在某个作用域内是特定的类型:

  1. typeof 类型守卫: 用于检查原始类型。
  2. instanceof 类型守卫: 用于检查类的实例。
  3. in 操作符守卫: 用于检查对象是否拥有某个属性。
  4. 自定义类型守卫: 创建一个返回 parameterName is Type 的函数。
// 使用自定义类型守卫
function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function moveSafely(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim(); // OK,TypeScript 在此块中知道 animal 是 Fish 类型
  } else {
    animal.fly(); // OK,TypeScript 在此块中知道 animal 是 Bird 类型
  }
}

c.c. 非空断言 (!)

非空断言操作符 (!) 是一种特殊的断言,它从一个类型中移除 nullundefined。当你确定一个值此时不会是 nullundefined 时,可以使用它。

function greet(name: string | null) {
  // 使用 ! 断言 name 在此时不为 null
  const hello = "Hello, " + name!.toUpperCase();
  console.log(hello);
}

greet("Alice");
// greet(null); // 编译时通过,但运行时会报错,需要谨慎使用!

d.d. 双重断言

如果两种类型互不兼容,TypeScript 会禁止它们之间的直接断言。但通过先断言为 anyunknown,可以绕过这个限制,这被称为双重断言。

interface Point {
  x: number;
}
interface Color {
  hex: string;
}

const point: Point = { x: 1 };

// const color = point as Color; // Error: 互不兼容

// 双重断言 (应极力避免)
const color = point as any as Color;

双重断言是非常危险的操作,它完全关闭了类型系统的保护。除非万不得已,否则绝不应该使用。