2024-10-25    2024-10-25    6032 字  13 分钟

TypeScript 闪念随手记……

:: 不要试图把一门语言的边边角角都一次性都搞清楚,没啥用,你又不准备成为“参考书”。一则平时使用的部分并没有那么多,二则脱离了实际应用的抽象文字并不容易记忆。

为什么需要 TypeScript

JavaScript 的每个值执行不同的操作时会有不同的行为。这听起来有点抽象,所以让我们举个例子,假设我们有一个名为 message 的变量,试想我们可以做哪些操作:

1
2
3
4
5
// Accessing the property 'toLowerCase'
// on 'message' and then calling it
message.toLowerCase();
// Calling 'message'
message();

第一行代码是获取属性 toLowerCase ,然后调用它。第二行代码则是直接调用 message 。

但其实我们连 message 的值都不知道呢,自然也不知道这段代码的执行结果。每一个操作行为都先取决于我们有什么样的值。

  • message 是可调用的吗?
  • message 有一个名为 toLowerCase 的属性吗?
  • 如果有,toLowerCase 是可以被调用的吗?
  • 如果这些值都可以被调用,它们会返回什么?

当我们写 JavaScript 的时候,这些问题的答案我们需要谨记在心,同时还要期望处理好所有的细节。

让我们假设 message 是这样定义的:

1
const message = "Hello World!";

你完全可以猜到这段代码的结果,如果我们尝试运行 message.toLowerCase() ,我们可以得到这段字符的小写形式。

那第二段代码呢?如果你对 JavaScript 比较熟悉,你肯定知道会报如下错误:

TypeError: message is not a function

如果我们能避免这样的报错就好了。

当我们运行代码的时候,JavaScript 会在运行时先算出值的类型(type),然后再决定干什么。所谓值的类型,也包括了这个值有什么行为和能力。 当然 TypeError 也会暗示性的告诉我们一点,比如在这个例子里,它告诉我们字符串 Hello World 不能作为函数被调用。

对于一些值,比如基本值 string 和 number,我们可以使用 typeof 运算符确认他们的类型。但是对于其他的比如函数,就没有对应的方法可以确认他们的类型了,举个例子,思考这个函数:

1
2
3
function fn(x) {
  return x.flip();
}

我们通过阅读代码可以知道,函数只有被传入一个拥有可调用的 flip 属性的对象,才会正常执行。但是 JavaScript 在代码执行时,并不会把这个信息体现出来。在 JavaScript 中,唯一可以知道 fn 在被传入特殊的值时会发生什么,就是调用它,然后看会发生什么。这种行为让你很难在代码运行前就预测代码执行结果,这也意味着当你写代码的时候,你会更难知道你的代码会发生什么。

从这个角度来看,类型就是描述什么样的值可以被传递给 fn,什么样的值则会导致崩溃。JavaScript 仅仅提供了动态类型(dynamic typing),这需要你先运行代码然后再看会发生什么。

替代方案就是使用静态类型系统(static type system),在代码运行之前就预测需要什么样的代码

在代码运行之前就找到错误,这就是静态类型检查器比如 TypeScript 做的事情。静态类型系统(Static types systems)描述了值应有的结构和行为。一个像 TypeScript 的类型检查器会利用这个信息,并且在可能会出错的时候告诉我们。

TypeScript 不仅在我们犯错的时候,可以找出错误,还可以防止我们犯错。

类型检查器因为有类型信息,可以检查比如说是否正确获取了一个变量的属性。也正是因为有这个信息,它也可以在你输入的时候,列出你可能想要使用的属性。

这意味着 TypeScript 对你编写代码也很有帮助,核心的类型检查器不仅可以提供错误信息,还可以提供代码补全功能。

TypeScript 的功能很强大,除了在你输入的时候提供补全和错误信息。还可以支持“快速修复”功能,即自动的修复错误,重构成组织清晰的代码。同时也支持导航功能,比如跳转到变量定义的地方,或者找到一个给定的变量所有的引用。

所有这些功能都建立在类型检查器上,并且跨平台支持。

类型注解

记住,我们并不需要总是写类型注解,大部分时候,TypeScript 可以自动推断出类型。这是一个特性,如果类型系统可以正确的推断出类型,最好就不要手动添加类型注解了。

类型注解并不是 JavaScript 的一部分。所以并没有任何浏览器或者运行环境可以直接运行 TypeScript 代码。这就是为什么 TypeScript 需要一个编译器,它需要将 TypeScript 代码转换为 JavaScript 代码,然后你才可以运行它。

谨记:类型注解并不会更改程序运行时的行为。

有哪些类型

类型可以出现在很多地方,不仅仅是在类型注解 (type annotations)中。我们不仅要学习类型本身,也要学习在什么地方使用这些类型产生新的结构。

我们先复习下最基本和常见的类型,这些是构建更复杂类型的基础。

原始类型, JavaScript 有三个非常常用的原始类型stringnumber 和 boolean,每一个类型在 TypeScript 中都有对应的类型。它们的名字跟你在 JavaScript 中使用 typeof 操作符得到的结果是一样的。

7 种原始数据类型:number bigint string boolean undefined null symbol

数组类型 - string[]Array<string>

any, TypeScript 有一个特殊的类型,any,当你不希望一个值导致类型检查错误的时候,就可以设置为 any 。

当你使用 constvar 或 let 声明一个变量时,你可以选择性的添加一个类型注解,显式指定变量的类型。不过大部分时候,这不是必须的,因为 TypeScript 会自动推断类型。

函数类型,是 JavaScript 传递数据的主要方法。TypeScript 允许你指定函数的输入值和输出值的类型(参数类型注解和返回值类型注解)。

对象类型,定义一个对象类型,我们只需要简单的列出它的属性和对应的类型。多个属性之间可以使用 , 或者 ; 分开属性,最后一个属性的分隔符加不加都行。

除了原始类型,最常见的类型就是对象类型了。

其中, 对象类型可以指定一些甚至所有的属性为可选的,你只需要在属性名后添加一个 ?

联合类型,TypeScript 类型系统允许你使用一系列的操作符,基于已经存在的类型构建新的类型。

注意,TypeScript 会要求 你做的事情,必须对每个联合的成员都是有效的。解决方案是用代码收窄联合类型。

后面,我们还会提到[[#字面量类型]] 。

类型别名和接口

我们已经学会在类型注解里直接使用对象类型和联合类型,这很方便,但有的时候,一个类型会被使用多次,此时我们更希望通过一个单独的名字来引用它。

:: 本质上都是为了方便复用。

你可以使用类型别名给任意类型一个名字。如:

1
2
3
4
5
6
7
8
// 为对象类型设置别名
type Point = {
  x: number;
  y: number;
};

// 为联合类型设置别名
type ID = number | string;

注意,别名是唯一的别名,你不能使用类型别名创建同一个类型的不同版本。

接口声明(interface declaration)是命名对象类型的另一种方式,如下:

1
2
3
4
interface Point {
  x: number;
  y: number;
}

类型别名和接口有什么不同呢?

大部分时候,你可以任意选择使用。两者最关键的差别在于类型别名本身无法添加新的属性,而接口是可以扩展的。

但其实我们也可以通过组合类型别名来实现类似扩展的效果。

类型断言

有的时候,你知道一个值的类型,但 TypeScript 不知道。TypeScript 仅仅允许类型断言转换为一个更加具体或者更不具体的类型。 就像类型注解一样,类型断言也会被编译器移除,并且不会影响任何运行时的行为。

谨记:因为类型断言会在编译的时候被移除,所以运行时并不会有类型断言的检查,即使类型断言是错误的,也不会有异常或者 null 产生。

前面说过,TypeScript 仅仅允许类型断言转换为一个更加具体或者更不具体的类型。有的时候,这条规则会显得非常保守,阻止了你原本有效的类型转换。如果发生了这种事情,你可以使用双重断言,先断言为 any (或者是 unknown),然后再断言为期望的类型。

1
const a = (expr as any) as T;

非空断言操作符 !, TypeScript 提供了一个特殊的语法,可以在不做任何检查的情况下,从类型中移除 null 和 undefined,这就是在任意表达式后面写上 ! ,这是一个有效的类型断言,表示它的值不可能是 null 或者 undefined

1
2
3
4
function liveDangerously(x?: number | null) {
  // No error
  console.log(x!.toFixed());
}

就像其他的类型断言,这也不会更改任何运行时的行为。重要的事情说一遍,只有当你明确的知道这个值不可能是 null 或者 undefined 时才使用 ! 。

字面量类型

除了常见的类型 string 和 number ,我们也可以将类型声明为更具体的数字或者字符串。

字面量类型本身并没有什么太大用,如果结合联合类型,就显得有用多了。举个例子,当函数只能传入一些固定的字符串时:

1
2
3
4
5
6
function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre");
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

当你初始化变量为一个对象的时候,TypeScript 会假设这个对象的属性的值未来会被修改,举个例子,如果你写下这样的代码:

1
2
3
4
const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

TypeScript 并不会认为 obj.counter 之前是 0, 现在被赋值为 1 是一个错误。换句话说,obj.counter 必须是 number 类型,但不要求一定是 0,因为类型可以决定读写行为。

这也同样应用于字符串:

1
2
3
4
5
6
declare function handleRequest(url: string, method: "GET" | "POST"): void;

const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);

// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

在上面这个例子里,req.method 被推断为 string ,而不是 "GET",因为在创建 req 和 调用 handleRequest 函数之间,可能还有其他的代码,或许会将 req.method 赋值一个新字符串比如 "Guess" 。所以 TypeScript 就报错了。

:: 这里写的多少是有点绕了,其实,就是 req 的类型没有确定导致的。在 req 中,method 属性只会被 TypeScript 推断为 string ,因为并没有根据知道它应该用在哪里,自然无从推断。只需要在定义和使用的时候,给它一个明确的类型就可以了。

有两种方式可以解决:

1.添加一个类型断言改变推断结果:

// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// Change 2
handleRequest(req.url, req.method as "GET");

修改 1 表示“我有意让 req.method 的类型为字面量类型 "GET",这会阻止未来可能赋值为 "GUESS" 等字段”。修改 2 表示“我知道 req.method 的值是 "GET"”.

2.你也可以使用 as const 把整个对象转为一个类型字面量

const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);

as const 效果跟 const 类似,但是对类型系统而言,它可以确保所有的属性都被赋予一个字面量类型,而不是一个更通用的类型比如 string 或者 number 。

类型收窄

在上一个章节,我们已经提到这方面的知识。也不准备多讲,基本上是符合直觉的。

函数

让我们来学习一下如何书写描述函数的类型(types)。

最简单描述一个函数的方式是使用 函数类型表达式(function type expression)。 它的写法有点类似于箭头函数:

1
2
3
4
5
6
7
8
9
function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}
 
function printToConsole(s: string) {
  console.log(s);
}
 
greeter(printToConsole);

语法 (a: string) => void 表示一个函数有一个名为 a ,类型是字符串的参数,这个函数并没有返回任何值。

如果一个函数参数的类型并没有明确给出,它会被隐式设置为 any

当然了,我们也可以使用类型别名(type alias)定义一个函数类型:

1
2
3
4
type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

函数类型表达式并不能支持声明属性,如果我们想描述一个带有属性的函数,我们可以在一个对象类型中写一个 调用签名(call signature)

1
2
3
4
5
6
7
8
type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
}

function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

注意这个语法跟函数类型表达式稍有不同,在参数列表和返回的类型之间用的是 : 而不是 =>

JavaScript 函数也可以使用 new 操作符调用,当被调用的时候,TypeScript 会认为这是一个构造函数(constructors),因为他们会产生一个新对象。你可以写一个 构造签名(Construct Signatures),方法是在调用签名前面加一个 new 关键词:

1
2
3
4
5
6
type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

一些对象,比如 Date 对象,可以直接调用,也可以使用 new 操作符调用,而你可以将调用签名和构造签名合并在一起:

1
2
3
4
interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): number;
}

:: 实际应用场景中,函数表达式是最常用的,调用签名可能会用,构造签名基本不用卅~

泛型函数,我们经常需要写这种函数,即函数的输出类型依赖函数的输入类型,或者两个输入的类型以某种形式相互关联。

让我们考虑这样一个函数,它返回数组的第一个元素:

1
2
3
function firstElement(arr: any[]) {
  return arr[0];
}

注意此时函数返回值的类型是 any,如果能返回第一个元素的具体类型就更好了。

在 TypeScript 中,泛型就是被用来描述两个值之间的对应关系。我们需要在函数签名里声明一个类型参数 (type parameter)

1
2
3
function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

通过给函数添加一个类型参数 Type,并且在两个地方使用它,我们就在函数的输入(即数组)和函数的输出(即返回值)之间创建了一个关联。现在当我们调用它,一个更具体的类型就会被判断出来。

1
2
3
4
5
6
// s is of type 'string'
const s = firstElement(["a", "b", "c"]);
// n is of type 'number'
const n = firstElement([1, 2, 3]);
// u is of type undefined
const u = firstElement([]);

尽管写泛型函数很有意思,但也容易翻车。如果你使用了太多的类型参数,或者使用了一些并不需要的约束,都可能会导致不正确的类型推断。如何写好泛型函数是一个值得进一步学习的问题,好在实际应用场景中,并不怎么需要我们书写泛型函数。

可选参数

JavaScript 中的函数经常会被传入非固定数量的参数,我们可以使用 ? 表示这个参数是可选的:

1
2
3
4
5
function f(x?: number) {
  // ...
}
f(); // OK
f(10); // OK

需要注意的是,尽管这个参数被声明为 number 类型,x 实际上的类型为 number | undefiend,这是因为在 JavaScript 中未指定的函数参数就会被赋值 undefined

回调中的可选参数

在你学习过可选参数和函数类型表达式后,你很容易在包含了回调函数的函数中,犯下面这种错误:

1
2
3
4
5
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

将 index? 作为一个可选参数,本意是希望下面这些调用是合法的:

1
2
myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

但 TypeScript 并不会这样认为,TypeScript 认为想表达的是回调函数可能只会被传入一个参数!

:: TypeScript:“我不要你以为,按我以为的来!” 😂

换句话说,myForEach 函数也可能是这样的:

1
2
3
4
5
6
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // I don't feel like providing the index today
    callback(arr[i]);
  }
}

TypeScript 会按照这个意思理解并报错,尽管实际上这个错误并无可能。

那如何修改呢?不设置为可选参数其实就可以:

1
2
3
4
5
6
7
8
9
function myForEach(arr: any[], callback: (arg: any, index: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

myForEach([1, 2, 3], (a, i) => {
  console.log(a);
});

在 JavaScript 中,如果你调用一个函数的时候,传入了比需要更多的参数,额外的参数就会被忽略。TypeScript 也是同样的做法。

当你写一个回调函数的类型时,不要写一个可选参数, 除非你真的打算调用函数的时候不传入实参。

剩余参数

不准备多讲~

对象类型

在 JavaScript 中,最基本的将数据成组和分发的方式就是通过对象。在 TypeScript 中,我们通过对象类型(object types)来描述对象。

对象类型可以是匿名的:

1
2
3
function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

也可以使用接口进行定义:

1
2
3
4
5
6
7
8
interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}

或者通过类型别名:

1
2
3
4
5
6
7
8
type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

:: 实际应用场景中,对象类型用的最多,单独拎出来重视一下。

对象类型中的每个属性可以说明它的类型、属性是否可选、属性是否只读等信息。

可选属性, 我们可以在属性名后面加一个 ? 标记表示这个属性是可选的。

只读属性,在 TypeScript 中,属性可以被标记为 readonly,这不会改变任何运行时的行为,但在类型检查的时候,一个标记为 readonly 的属性是不能被写入的。

属性继承,对接口使用 extends 关键字允许我们有效的从其他声明过的类型中拷贝成员,并且随意添加新成员。

交叉类型,TypeScript 也提供了名为交叉类型(Intersection types)的方法,用于合并已经存在的对象类型。交叉类型的定义需要用到 & 操作符。

:: 一直感觉这里叫 “交叉” 很奇怪,因为明明应该是两个类型组合的,结果,它指的是两个类型组合时,其内部属性相同时,取其类型的交集。如此,便合理了。

泛型对象类型,见泛型。

类和模块

类就不多说了,基本不用系列。模块详见另一篇博文 「 [[模块化编程]] 」。