2

一、背景

在日常的开发工作中,我发现我们的前端工程都支持 TypeScript,而团队内的同学在写代码时还是以 JavaScript 为主,而其它的一些用到了 TypeScript 的代码,有很多都是在写 “AnyScript”,用到的 TypeScript 的特性很少,也没有把使用 TypeScript 所带来的优点发挥出来,于是就有了这篇分享。

相对于只使用 JavaScript 来说,使用 TypeScript 的能带来如下好处:

  1. 得益于 TypeScript 的静态类型检测,可以让部分 JavaScript 错误在开发阶段就被发现并解决;
  2. 使用 TypeScript 可以增加代码的可读性和可维护性。在复杂的大型应用中,它能让应用更易于维护、迭代,且稳定可靠,也会让你更有安全感;
  3. TypeScript 的类型推断与 IDE 结合带来更好的代码智能提示、重构,让开发体验和效率有了极大的提升;

这篇文章主要介绍 TypeScript 中的一些基础特性、进阶特性的使用,并结合个人的理解和实践经验,适用于对 TypeScript 已经有比较基础的了解或者已经实际用过一段时间的前端开发者,希望能对大家有所帮助。

二、基础

2.1 原始类型

这个比较基础,参考 TypeScript: Documentation - built-in-types

2.2 内置全局对象

JavaScript 中内置的装箱类型NumberStringBoolean 等)以及其它内置对象(DateErrorArrayMapSetRegExpPromise 等)在 TypeScript 中都有其对应的同名类型。

声明类型时需要注意这一点,在能使用 numberstringboolean 等原始类型来标注类型地方就不要使用 NumberStringBoolean 等包装类的类型去标注类型,二者在 TypeScript 中不是完全等价的,如下面示例所示:

let primitiveNumber: number = 123;
let wrappedNumber: Number = 123;

wrappedNumber = primitiveNumber;
primitiveNumber = wrappedNumber; // @error: Type 'Number' is not assignable to type 'number'. (2322)

let primitiveString: string = 'hello';
let wrappedString: String = 'hello';

wrappedString = primitiveString;
primitiveString = wrappedString; // @error: Type 'String' is not assignable to type 'string'. (2322)

在实际开发场景中,我们几乎使用不到 NumberStringBoolean 等类型,它们并没有什么特殊的用途。我们在写 JavaScript 时,通常不会使用 NumberStringBoolean 等构造函数来 new 一个相应的实例。

2.3 字面量类型

除了原始类型 stringnumberboolean 之外,我们还可以将类型标注为特定的字符串和数字、布尔值。将变量类型标注为字面量类型后,它的值就需要与字面量类型对应的字面量值匹配,如下面示例所示:

const num1: 123 = 123;
const num2: 123 = 1234; // @error: Type '1234' is not assignable to type '123'.(2322)

const str1: 'Hello' = 'Hello';
const str2: 'Hello' = 'Hello world!'; // @error: Type '"Hello world!"' is not assignable to type '"Hello"'.(2322)

const bool1: true = true;
const bool2: true = false; // @error: Type 'false' is not assignable to type 'true'.(2322)

2.4 联合类型 & 枚举

2.4.1 联合类型

在声明变量的类型时,我们一般不会将它限制为某一个字面量类型,一个只能有一个值的变量并没有什么用。所以我们一般会将字面量类型与联合类型搭配使用。

联合类型用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合,如下面示例所示:

let color: 'blue' | 'green' | 'red';

color = 'green';
color = 'blue';
color = 'red';
color = 'yellow'; // @error: Type '"yellow"' is not assignable to type '"blue" | "green" | "red"'.(2322)

function printText(s: string, alignment: 'left' | 'right' | 'center') {
  // ...
}

printText('Hello', 'left');
printText('Hello', 'center');
printText('Hello', 'top'); // @error: Argument of type '"top"' is not assignable to parameter of type '"left" | "right" | "center"'.

在一些场景中,TypeScript 针对联合类型做了类型缩小优化,当联合的成员同时存在子类型和父类型时,类型会只保留父类型,如下面示例所示:

/* 下面的类型会缩小到只保留父类型 */
type Str = 'string' | string; // 类型为 string
type Num = 2 | number; // 类型为 number
type Bool = true | boolean; // 类型为 boolean

这个优化削弱了 IDE 的自动提示能力。在 TypeScript 官方仓库的 issue Literal String Union Autocomplete · Issue #29729 · microsoft/TypeScript 的讨论中,TypeScript 官方提供了一个小技巧来使 IDE 的自动提示生效,如示例所示:

/* 在 IDE 中,给 color1 赋值时,不会获得提示 */
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string;
const color1: BorderColor = 'black';

/* 给父类型添加 “& {}” 后,就可以让 IDE 的自动提示生效 */
type BGColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {};
const color2: BGColor = 'black';

2.4.2 枚举

枚举是 TypeScript 具有的少数几个不是 JavaScript 类型级扩展的功能之一,其用法可参考TypeScript: Handbook - Enums。它与其它的类型有些不同,枚举兼具值和类型于一体。如示例所示:

 title=

在将 JavaScript 项目逐步升级到 TypeScript 时,项目中存在很多老的 JavaScript 代码,你可以将 TypeScript 中声明的枚举导入到 JavaScript 代码中使用。不过更推荐的做法是,使用诸如 airbnb/ts-migrate 之类的工具,快速将项目的代码都转成 TypeScript,然后将类型检查从宽松逐步过渡到严格。

常量枚举

通过添加 const 修饰符来定义常量枚举,常量枚举定义在编译为 JavaScript 之后会被抹除,可以在一定程度上减少编译后的 JavaScript 代码量,枚举成员的值会被直接内联到使用了枚举成员的地方,使编译后的产物结构更清晰,可读性更高。如示例所示:

 title=

2.5 数组 & 元组

2.5.1 数组

在 TypeScript 中声明数组时,可以指定数组元素的类型,如下面示例所示:

const numArr1: number[] = [1, 2, 3];
const strArr1: string[] = ['hello', 'world'];

const numArr2 = [1, 2, 3]; // 类型为 number[]
const strArr2 = ['hello', 'world']; // 类型为 string[]

你也可以用 Array<number> 之类的方式来声明数组,这个将在泛型部分讲到。这两种方式本质上并没有区别,更推荐使用 number[] 的方式来声明数组,因为这种方式代码量更少。

2.5.2 元组

基本用法

元组类型与数组类型有些相似,数组和元组转译为 JavaScript 后都是数组。它与数组类型的区别在于它可以确切的声明数组中包含多少个元素以及各个元素的具体类型,如下面示例所示:

type StringNumberPair = [string, number];

const tuple1: StringNumberPair = ['age', 21];
const tuple2: StringNumberPair = ['age', 21, 22]; // @error: Type '[string, number, number]' is not assignable to type 'StringNumberPair'.(2322)

React 中的 useState hook 的返回值就是一个元组,它的类型定义类似于:

(state: State) => [State, SetState];

元组还经常用于声明函数的参数的类型,如下面示例所示:

function add(...args: [number, number]) {
  const [x, y] = args;
  return x + y;
}
命名元组

上面示例中这种方式虽然可以声明函数的参数类型,但是没有包含函数的参数名信息,如果参数数量比较多的话,这种声明方式看起来就比较累了,于是官方在 TypeScript 4.0 中支持了给元组的成员命名,如下面示例所示:

type Tag = [name: string, value: string];

const tags: Tag[] = [
  ['成功', 'SUCEESS'],
  ['失败', 'FAILURE'],
];

function add(a: number, b: number) {}

// 在 4.0 时, 这里获取到的参数类型为 [a: number, b: number]
type CenterMapParams = Parameters<typeof add>;

// 在 3.9 时, 类型看起来会是下面这样
type OldCenterMapParams = [number, number];

2.6 函数

2.6.1 基本用法

函数是在 JavaScript 中传递数据的主要方式,在 TypeScript 中你可以为函数的参数和返回值指定类型,如下面示例所示:

// 声明参数 a 和 b 的类型为 number
function add(a: number, b: number) {
  console.log(`${a} + ${b} = ${a + b}`);
}

// 声明返回值的类型为 number
function getRandom(): number {
  return Math.random();
}

2.6.2 函数重载

JavaScript 是一门动态语言,针对同一个函数,它可以有多种不同类型的参数与返回值。而在 TypeScript 中,也可以相应地表达不同类型的参数和返回值的函数。

函数重载需要包含重载签名和实现签名,重载签名的列表的各个成员必须是函数实现签名的子集,如下面示例所示:

// 重载签名
function len(s: string): number;
function len(arr: any[]): number;
// 实现签名
function len(x: any) {
  return x.length;
}

函数实现时,TypeScript 不会限制函数实现中的返回值与重载签名严格匹配,返回值类型只需要与实现签名兼容就行。如下面的示例所示:

function reflect(str: string): string;
function reflect(num: number): number;
function reflect(strOrNum: string | number): string | number {
  if (typeof strOrNum === 'string') {
    // 参数为 string 类型时,参考对应的重载签名,返回值应该为 string 类型
    // 实际上你返回一个 number 也不会报错,仅需要与实现签名 string | number 兼容
    return 123456;
  } else if (typeof strOrNum === 'number') {
    // 参数为 number 类型时,参考对应的重载签名,返回值应该为 number 类型
    // 返回一个与实现签名 string | number 不兼容的类型时会报错
    return false; // @error: Type 'boolean' is not assignable to type 'string | number'.(2322)
  } else {
    throw new Error('Invalid param strOrNum.');
  }
}

虽然 TypeScript 允许上面这种行为,但是实际开发场景中我们还是要避免这么写代码。

不要在更精确的重载签名之前放置更宽泛的重载签名,TypeScript 会从上到下查找函数重载列表中与入参类型匹配的类型,并优先使用第一个匹配的重载定义。因此,放在前面函数重载签名越精确越好,如下面反例所示:

// 下面这行添加了一个宽泛的重载签名
function len(val: any): undefined;
// 重载签名
function len(s: string): number;
function len(arr: any[]): number;
// 实现签名
function len(x: any) {
  return x.length;
}

const r1 = len(''); // 匹配上第一个重载了,类型为 undefined

如果函数仅仅只有参数个数上的区别,那么直接使用可选参数来声明就行,没有必要使用重载,如下面反例所示:

function diff(one: string): number; // 这行重载签名写或者不写是没有区别的
function diff(one: string, two?: string): number {
  return 0;
}

更多函数类型的用法参考 Documentation - More on Functions

2.7 对象

除了原始类型之外的其它所有类型都是对象类型。要定义对象类型,我们只需要列出它的属性和类型。如下面示例所示:

// 一个接受对象类型参数的函数
function printCoord(pt: { x: number; y: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 3, y: 7 });

2.7.1 可选属性

在属性名后增加 ? 修饰即表示可选属性,如下面示例所示:

function printCoord(pt: { x: number; y?: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 3 });
printCoord({ y: 3 }); // @error: Property 'x' is missing in type ……

2.7.2 只读属性

在属性名前增加 readonly 即表示属性为只读,如下面示例所示:

function printCoord(pt: { readonly x: number; y?: number }) {
  pt.x = 4; // @error: Cannot assign to 'x' because it is a read-only property.(2540)
}

printCoord({ x: 3 });

这里 readonly 只是在 TypeScript 静态类型检查层面上将属性 x 的设置行为进行了拦截。在 JavaScript 运行时并不会产生影响。

2.7.3 object

object 类型表示任何非原始类型(stringnumberbigintbooleansymbolnullundefined)的类型。如下面示例所示:

const val1: object = 123456; // @error: (2322)
const val2: object = 'hello'; // @error: (2322)
const val3: object = undefined; // @error: (2322)
const val4: object = null; // @error: (2322)

// 下面这些不会报错
const val5: object = { name: 'Jay' };
const val6: object = () => {};
const val7: object = [];

2.7.4 Object vs object vs {}

在进行对象类型标注时,我们可能会将对象字面量的 {} 、内置全局对象 Objectobject 混淆,所以这里再总结一下使用场景:

  1. 对象的装箱类型为 Object,如章节 2.2 中提到的原因,不建议使用装箱类型来标注类型;
  2. 当确定某个值是非原始值类型时,但又不知道它是什么对象类型时,可以使用 object ,但是更推荐使用映射类型例如 Record<keyof any, unknown>   来表示;(keyof any 会根据 tsconfig.json 中的 keyofStringsOnly 配置来决定这里的类型是 string 还是 string | number | symbol
  3. 类型 {} 可以表示任何非 null / undefined 的值,因此从类型安全的角度来说不推荐使用 {} 。当你想表示一个空对象时,可以使用 Record<keyof any, never> 代替;

2.8 类型别名 & 接口类型

2.8.1 类型别名

在之前的大部分示例代码中,我们将对象类型和联合类型直接标注在变量或者属性的类型上。直接标注虽然很方便,但如果要多次使用相同的类型来标注时,一处类型变动,就需要修改所有用了相同类型标注的地方,这样会导致类型维护困难。因此,为了更好的复用类型, 我们可以为类型取一个名称,然后在所用用到这个类型的地方标注这个类型别名,这样就能解决这个问题了。如下面示例所示:

type User = {
  id: number;
  name: string;
  age: number;
};

function getCurrentUser(): User {
  return { id: 1, name: 'Jay', age: 21 };
}

function getUserList(): User[] {
  return [{ id: 1, name: 'Jay', age: 21 }];
}
交叉类型

当你想把多个对象类型合并到一起的时候,你可能会把多个对象的属性重新声明成一个对象,这样还是可能会导致类型维护困难。而交叉类型就可以解决这个问题,交叉类型主要用于组合现有的对象类型,它的用法也很简单,将 & 运算符放在两个对象类型之间即可,如下面示例所示:

type Colorful = {
  color: string;
};

type Circle = {
  radius: number;
};

type ColorfulCircle = Colorful & Circle;

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

2.8.2 接口类型

接口类型是 TypeScript 中声明命名对象类型的另一种方式,如下面示例所示:

// 声明了 Point 接口,要求必须要有 x 和 y 两个属性,且类型为 number
interface Point {
  x: number;
  y: number;
}

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

// 传入了符合 Point 接口描述的类型的对象
printCoord({ x: 100, y: 100 });

与对象类型一样,接口类型可以在属性前添加 readonly 将属性变为只读类型,也可以在属性名后添加 ? 将属性变为可选类型,如下面示例所示:

type User = {
  id: number;
  name: string;
  age: number;
};

function getCurrentUser(): User {
  return { id: 1, name: 'Jay', age: 21 };
}

function getUserList(): User[] {
  return [{ id: 1, name: 'Jay', age: 21 }];
}
声明合并

接口具有声明合并的特性,两个同名的接口声明会合并成一个接口声明,如下面示例所示:

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

上面例子中的接口声明等价于下面这段接口声明:

interface Box {
  height: number;
  width: number;
  scale: number;
}
继承

前面提到了类型别名可以通过交叉类型将多个对象类型进行组合,接口也可以使用交叉类型进行组合,如下面示例所示:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

除了使用交叉类型进行组合之外,还可以使用 extends 关键字进行组合,如下面示例所示:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const obj1: ColorfulCircle = {
  // @error: Property 'radius' is missing in type……
  color: 'blue',
};

const obj2: ColorfulCircle = {
  color: 'blue',
  radius: 50,
};

2.8.3 类型别名 vs 接口类型

既然类型别名和接口类型都可以声明命名对象类型,那它们之前有哪些区别呢?又有哪些适用场景呢?

区别
  1. 类型别名可以声明原始类型、联合类型、交叉类型、元组等类型,而接口不行;
  2. 类型别名不支持声明合并,而接口支持;
  3. 类型别名主要使用交叉类型来组合对象,而接口主要使用 extends 关键字来组合对象;
  4. 在 TypeScript 4.2 版本之前,当类型检查报错时,使用接口类型在一些情况下可以获得更具体的错误提示信息,如下面示例所示;
/* 使用接口时,错误信息中将会始终显示接口名 */

interface Mammal {
  name: string;
}

function echoMammal(m: Mammal) {}

echoMammal({ name: 12343 }); // 鼠标悬停提示错误与类型 Mammal 有关

/* 使用类型别名时,当类型未经过处理,可以正确显示类型名称 */

type Lizard = {
  name: string;
};

function echoLizard(l: Lizard) {}

echoLizard({ name: 12345 }); // 鼠标悬停提示错误与类型 Lizard 有关

/* 使用类型别名时,当类型经过处理时,错误信息就只会显示转换后的结果的类型,而不是类型名称 */

type Arachnid = Omit<{ name: string; legs: 8 }, 'legs'>;

function echoSpider(l: Arachnid) {}

echoSpider({ name: 12345, legs: 8 }); // 鼠标悬停提示错误与类型 Pick<{ name: string; legs: 8; }, "name"> 有关
使用场景

Interfaces create a single flat object type that detects property conflicts, which are usually important to resolve! Intersections on the other hand just recursively merge properties, and in some cases produce never. Interfaces also display consistently better, whereas type aliases to intersections can't be displayed in part of other intersections. Type relationships between interfaces are also cached, as opposed to intersection types as a whole. A final noteworthy difference is that when checking against a target intersection type, every constituent is checked before checking against the "effective"/"flattened" type.

—— Preferring Interfaces Over Intersections - Performance · microsoft/TypeScript Wiki

从上面这段引用我们可以得知:

  1. 接口会创建扁平的对象类型来检测属性是否冲突,解决这些冲突通常是很重要的。而交叉类型只是递归地合并属性,在某些情况下将会产生 never 类型;在错误信息中接口名会显示的比较好,而交叉类型则不行。
  2. TypeScript 编译器会缓存接口间的类型关系,使用接口能获得更好的性能,特别是在项目比较复杂时;

结合二者的区别以及性能差异,我们可以得出结论:接口类型更适合用来声明对象类型,以及进行对象组合、继承;类型别名更适合用于描述非结构化类型以及类型转换等场景。

2.9 特殊类型

any

TypeScript 中有一个特殊类型 any ,它是官方提供的一个选择性绕过静态类型检测的作弊方式。你可以在不希望特定值导致类型检查错误时使用它。

当一个值是 any 类型时,你可以对它进行任何操作,例如:访问它的任何属性即使该属性可能不存在、像函数一样调用它,以及任何其它在语法上合法的东西,如示例所示:

let anything: any = {};

anything.doAnything(); // 不会提示错误
anything = 1; // 不会提示错误
anything = 'x'; // 不会提示错误

let num: number = anything; // 不会提示错误
let str: string = anything; // 不会提示错误

当我们将一个基于 JavaScript 的应用改造成 TypeScript 的过程中,我们可以借助 any 来选择性添加和忽略对某些 JavaScript 模块的静态类型检测,直至逐步替换掉所有的 JavaScript。或者已经引入了缺少类型注解的第三方组件库时,就可以把这些值全部注解为 any 类型。

但是从长远来看,使用 any 是一个坏习惯。如果一个 TypeScript 应用中充满了 any,此时静态类型检测起不到作用,也就与直接使用 JavaScript 几乎没有区别了。因此,除非有充足的理由,否则应当尽量避免使用 any 。在项目中,我们可以在 tsconfig.json 中开启 noImplicitAny   的配置项来限制 any 的使用。

unknown

unknownany 相似 ,它也可以表示任何值,但在类型上比 any 更安全。我们可以将任意类型的值赋值给 unknown,但 unknown 类型的值只能赋值给 unknownany,如下面示例所示:

let value: unknown;
let num: number = value; // @error: Type 'unknown' is not assignable to type 'number'. (2322)
let anything: any = value; // 不会提示错误

使用 unknown 后,TypeScript 会对它做类型检测。如果不缩小类型,对 unknown 执行的任何操作都会出现错误。因此,对于未知类型的数据,使用 unknown 比使用 any 更好,如下面示例所示:

function fn1(value: any) {
  return value.toFixed(); // 不报错
}

function fn2(value: unknown) {
  return value.toFixed(); // @error: 'value' is of type 'unknown'. (2571)
}

function fn3(value: unknown) {
  if (typeof value === 'number') {
    value.toFixed(); // 此处 hover 提示类型是 number,不会提示错误
  }
}

never

never 类型表示不携带任何类型。作为函数返回值时,意味着函数抛出异常或程序终止执行,如下面示例所示:

// 函数因为永远不会有返回值,所以它的返回值类型就是 never
function ThrowError(msg: string): never {
  throw Error(msg);
}

// 如果函数代码中是一个死循环,那么这个函数的返回值类型也是 never
function InfiniteLoop(): never {
  while (true) {}
}

// 当联合类型被缩小到什么类型信息都没有时
function fn1(x: string | number) {
  if (typeof x === 'string') {
    // x 的类型在这个分支被缩小为 string
  } else if (typeof x === 'number') {
    // x 的类型在这个分支被缩小为 number
  } else {
    x; // 类型是 'never'!
  }
}

never 是所有类型的子类型,它可以赋值给所有类型,反过来,除了 never 自身以外的其它类型都不能赋值给 never 类型,如示例所示。

let unreachable: never = 1; // @error: (2322)

unreachable = 'string'; // @error: (2322)
unreachable = true; // @error: (2322)

let num: number = unreachable; // ok
let str: string = unreachable; // ok
let bool: boolean = unreachable; // ok

void

TypeScript 中 void 表示没有返回值的函数。即如果函数没有返回值,那它的类型就是 void,如示例所示:

// 鼠标悬停在函数名上,显示函数的返回值类型为 void
function noop() {
  return;
}

我们可以把 undefined 值或类型是 undefined 的变量赋值给 void 类型的变量,反过来,类型是 void 但值是 undefined 的变量不能赋值给 undefined 类型,如示例所示:

const userInfo: { id?: number } = {};

let undefinedType: undefined = undefined;
let voidType: void = undefined;

voidType = undefinedType; // ok
undefinedType = voidType; // @error: Type 'void' is not assignable to type 'undefined'. (2322)

function fn1(): void {
  return undefined;
}

function fn2(): undefined {
  const result: void = undefined;
  return result; // @error: Type 'void' is not assignable to type 'undefined'. (2322)
}

在给函数标注返回值类型时,返回值的类型应该仅为 void 或者为其它类型,不推荐将 void 与其它类型进行联合,如示例所示:

// 什么值都不会返回时使用 void
function fn1(): void {
  // ……
}

// 反例:函数某些情况下会有返回值,虽然类型检查能通过,但不推荐这么写。
function fn3(val: unknown): number | string | void {
  if (typeof val === 'number' || typeof val === 'string') {
    return val;
  }
}

// 改进:将 void 替换成 undefined 。
function fn2(val: unknown): number | string | undefined {
  if (typeof val === 'number' || typeof val === 'string') {
    return val;
  }

  return;
}

2.10 类型断言

2.10.1 基本用法

当你知道某个值的类型信息,但是 TypeScript 不知道,就可以使用类型断言。如下面示例所示:

const foo = {};
foo.bar = 123; // Error: 'bar' 属性不存在于 ‘{}’
foo.bas = 'hello'; // Error: 'bas' 属性不存在于 '{}'

这段代码发出了错误警告,因为 foo 的类型推断为 {},即没有属性的对象。因此,你不能在它的属性上添加 barbas,你可以通过类型断言来避免此问题,如下面示例所示:

interface Foo {
  bar: number;
  bas: string;
}

const foo = {} as Foo; // 类型为 Foo

foo.bar = 123;
foo.bas = 'hello';

/**
 * 下面的这种方式与上面的没有任何区别,但是由于尖括号格式会
 * 与 JSX 产生语法冲突,因此更推荐使用 as 语法。
 */
const foo1 = <Foo>{}; // 类型为 Foo

类型断言仅允许将类型断言为一个更具体的或者不太具体的类型,即仅在父子、子父类型之间可以使用类型断言进行转换。如下面示例所示;

/**
 * 断言成更具体的类型
 */
function fn1(event: Event) {
  const mouseEvent = event as MouseEvent;
}

/**
 * 断言成不那么具体的类型
 */
function fn2(event: MouseEvent) {
  const mouseEvent = event as Event;
}

/**
 * 断言成不可能的类型
 */
function fn3(event: Event) {
  const element = event as HTMLElement; // Error: 'Event' 和 'HTMLElement' 中的任何一个都不能赋值给另外一个
}

2.10.2 双重断言

使用双重断言可以将任何一个类型断言为任何另一个类型,如下面示例所示,但是由于这种做法可能导致运行时错误,所以不推荐这么做。

const num = 123 as any as string; // 类型为 string

const str = 'hello' as unknown as number; // 类型为 number

function fn3(event: Event) {
  const element = event as unknown as HTMLElement; // 类型为 HTMLElement
}

2.10.3 非空断言

在值(变量、属性)的后边添加 ! 断言操作符,它可以用来排除值为 nullundefined 的情况,如下面示例所示:

let mayNullOrUndefinedOrString: null | undefined | string;

mayNullOrUndefinedOrString!.toString(); // ok
mayNullOrUndefinedOrString.toString(); // @error: 'mayNullOrUndefinedOrString' is possibly 'null' or 'undefined'.(18049)

2.10.4 常量断言

使用 字面量值 + as const 语法结构可以进行常量断言,对数据进行常量断言后,它的类型就变成了字面量类型且它的值不能再被修改,如下面示例所示:

let str = 'Hello world!' as const; // 类型为 "Hello world"
str = '123'; // Error: 2322

const readOnlyArr = [0, 1] as const; // 类型为 readonly [0, 1]
readOnlyArr[1] = 123; // Error: 2540

2.11 控制流分析

JavaScript 文件中代码流动方式会影响整个程序的类型。让我们来看一个例子:

const users = [{ name: 'Ahmed' }, { name: 'Gemma' }, { name: 'Jon' }]; // users 类型为 {name: string}[]
const jon = users.find(u => u.name === 'jon');

在这个例子中,find 可能会失败,因为名字叫 “jon” 的用户并不一定存在,因此变量 jon 的类型为 { name: string } | undefined 。而当你把鼠标悬停在下面代码示例所示的三处 jon 上时,你将会看到类型如何根据 jon 所在的位置而变化:

if (jon) {
  // 类型为 { name: string } | undefined
  jon; // 类型为 { name: string }
} else {
  jon; // 类型为 undefined
}

像上面这种基于可达性的代码分析称为控制流分析,TypeScript 在遇到类型保护和赋值时使用这种流分析来缩小类型。当分析变量时,控制流可以一次又一次地分离和重新合并,并且可以观察到该变量在每个位置具有的不同类型。

另一个控制流分析的例子:

interface User {
  id: string;
  name: string;
  age: number;
}

type Action = { type: 'add'; user: User } | { type: 'delete'; id: string };

function addUser(user: User) {}
function deleteUser(id: string) {}

function reducer(action: Action) {
  switch (action.type) {
    case 'add':
      addUser(action.user); // action 是 "{ type: 'add', user: User }" 类型
      break;
    case 'delete':
      deleteUser(action.id); // action 是 "{ type: 'delete', id: string }" 类型
      break;
    default:
      throw new Error('Invalid action.');
  }
}

上面这段代码中,联合类型 Action 中的对象成员都具有属性 type,TypeScript 通过分析 switch 语句,就可以在对应的 case 分支中将类型缩小。

2.12 类型守卫

类型守卫是指通过代码来影响代码流分析。TypeScript 可以使用现有的 JavaScript 行为在运行时对值进行验证以影响代码流。

JavaScript 中的一种常见模式是使用 typeofinstanceof 在运行时检查表达式的类型。TypeScript 可以理解这些条件,并在 if 代码块中使用时会相应地更改类型推断,如下面示例所示:

let x: unknown;

// 使用 typeof 类型守卫
if (typeof x === 'string') {
  x.substring(1);
  x.subtr(2); // @error: Property 'subtr' does not exist on type 'string'. Did you mean 'substr'?(2551)
}

if (x instanceof Array) {
  x.split(''); // @error: Property 'split' does not exist on type 'any[]'.(2339)
  x.forEach(item => {
    console.log(item);
  });
}

除了 typeofinstanceof 之外,你还可以使用 in类型谓词来做实现类型守卫,如下面示例所示:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

/* 使用 in 运算符缩小类型 */

function move1(animal: Fish | Bird) {
  if ('swim' in animal) {
    return animal.swim(); // 鼠标悬停在 animal 上提示类型为 Fish
  }

  return animal.fly(); // 鼠标悬停在 animal 上提示类型为 Bird
}

/* 使用类型谓词实现类型守卫 */

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move2(animal: Fish | Bird) {
  if (isFish(animal)) {
    return animal.swim(); // 鼠标悬停在 animal 上提示类型为 Fish
  }

  return animal.fly(); // 鼠标悬停在 animal 上提示类型为 Bird
}

三、未完待续

由于时间和精力有限,第一部分内容就分享到这里。后面还会给大家带来 TypeScript 泛型、类型编程等内容,敬请期待。


Jason
463 声望22 粉丝

试看将来的环球,必是赤旗的世界!