Skip to content
微信公众号

数据类型

TypeScript原始类型

在 JavaScript 中,原始类型指的是非对象且没有方法的数据类型,它包括 string、number、bigint、boolean、undefined 和 symbol 这六种 (null 是一个伪原始类型,它在 JavaScript 中实际上是一个对象,且所有的结构化类型都是通过 null 原型链派生而来)。

  • string,存放字符串,非严格模式下允许设置null
  • number,存放数字,包括Nan、Infinity,非严格模式下允许设置null
  • boolean,存放true或false,非严格模式下允许设置null
  • void,空值,一般是在函数没有返回值的时候,标记函数返回值类型,存放undefined,非严格模式下允许设置null
  • null,存放null
  • undefined,存放undefined
  • symbol,存放symbol类型的值

字符串

在 JavaScript 语言中,原始类型值是最底层的实现,对应到 TypeScript 中同样也是最底层的类型。

ts
let firstname: string = 'Captain'; // 字符串字面量
let familyname: string = String('S'); // 显式类型转换
let fullname: string = `my name is ${firstname}.${familyname}`; // 模板字符串

说明:所有 JavaScript 支持的定义字符串的方法,我们都可以直接在 TypeScript 中使用。

数字

同样,我们可以使用number类型表示 JavaScript 已经支持或者即将支持的十进制整数、浮点数,以及二进制数、八进制数、十六进制数,具体的示例如下所示:

ts
/** 十进制整数 */
let integer: number = 6;
/** 十进制整数 */
let integer2: number = Number(42);
/** 十进制浮点数 */
let decimal: number = 3.14;
/** 二进制整数 */
let binary: number = 0b1010;
/** 八进制整数 */
let octal: number = 0o744;
/** 十六进制整数 */
let hex: number = 0xf00d;

如果使用较少的大整数,那么我们可以使用bigint类型来表示,如下代码所示。

ts
let big: bigint =  100n;

请注意:虽然number和bigint都表示数字,但是这两个类型不兼容。

布尔值

我们可以使用boolean表示 True 或者 False,如下代码所示。

ts
/** TypeScript 真香 为 真 */
let TypeScriptIsGreat: boolean = true;
 /** TypeScript 太糟糕了 为 否 */
let TypeScriptIsBad: boolean = false;

Symbol

自 ECMAScript 6 起,TypeScript 开始支持新的Symbol原始类型, 即我们可以通过Symbol构造函数,创建一个独一无二的标记;同时,还可以使用symbol表示如下代码所示的类型。

ts
let sym1: symbol = Symbol();
let sym2: symbol = Symbol('42');

当然,TypeScript 还包含 Number、String、Boolean、Symbol 等类型(注意区分大小写)。

静态类型检测

在编译时期,静态类型的编程语言即可准确地发现类型错误,这就是静态类型检测的优势。

在编译(转译)时期,TypeScript 编译器将通过对比检测变量接收值的类型与我们显示注解的类型,从而检测类型是否存在错误。如果两个类型完全一致,显示检测通过;如果两个类型不一致,它就会抛出一个编译期错误,告知我们编码错误,具体示例如下代码所示:

ts
const trueNum: number = 42;
const fakeNum: number = "42"; // ts(2322) Type 'string' is not assignable to type 'number'.

在以上示例中,首先我们声明了一个数字类型的变量trueNum,通过编译器检测后,发现接收值是 42,且它的类型是number,可见两者类型完全一致。此时,TypeScript 编译器就会显示检测通过。

而如果我们声明了一个string类型的变量fakeNum,通过编译器检测后,发现接收值为 "42",且它的类型是number,可见两者类型不一致 。此时,TypeScript 编译器就会抛出一个字符串值不能为数字类型变量赋值的ts(2322) 错误,也就是说检测不通过。

Object类型

TypeScript中的Object类型并不单指普通的对象类型,而是泛指所有的非原始类型,即对象、数组和函数。例如:

ts
export {} //确保跟其他示例没有冲突

// const foo: object = {};  
// const foo: object = [];
const foo: object = function(){};

如果限制类型为普通对象的话,可以使用对象字面量的语法去标记类型,并且赋值的对象结构与类型完全一致。

ts
const obj: {foo:number} = {foo:123}

数组类型(Array)

TypeScript创建数组有两种方式,一种是使用Array泛型

ts
/** 子元素是数字类型的数组 */
let arrayOfNumber: Array<number> = [1, 2, 3];
/** 子元素是字符串类型的数组 */
let arrayOfString: Array<string> = ['x', 'y', 'z'];

第二种是使用元素类型+方括号的形式

ts
/** 子元素是数字类型的数组 */
let arrayOfNumber: number[] = [1, 2, 3];
/** 子元素是字符串类型的数组 */
let arrayOfString: string[] = ['x', 'y', 'z'];

以上两种定义数组类型的方式虽然本质上没有任何区别,但是我更推荐使用 [] 这种形式来定义。一方面可以避免与 JSX 的语法冲突,另一方面可以减少不少代码量。

如果我们明确指定了数组元素的类型,以下所有操作都将因为不符合类型约定而提示错误。

ts
let arrayOfNumber: number[] = ['x', 'y', 'z']; // 提示 ts(2322)
arrayOfNumber[3] = 'a'; // 提示 ts(2322)
arrayOfNumber.push('b'); // 提示 ts(2345)
let arrayOfString: string[] = [1, 2, 3]; // 提示 ts(2322)
arrayOfString[3] = 1; // 提示 ts(2322)
arrayOfString.push(2); // 提示 ts(2345)

元组类型(tuple)

元组类型是一种特殊的数据结构,元组其实就是一个明确元素数量和元素类型的数组。各个元素类型可以不完全相同,在TypeScript中可以使用类似字面量的方式来定义。可以通过数组下标和解构的方式获取内容,在React的useState中就是返回元组的方式。

ts
import { useState } from 'react';
function useCount() {
  const [count, setCount] = useState(0);
  return ....;
}

在 JavaScript 中并没有元组的概念,作为一门动态类型语言,它的优势是天然支持多类型元素数组。

我们假设以下两个数组的元素类型如下代码所示:

ts
[state, setState]
[setState, state]

从上面可以看出,state 是一个类型为 State 的对象,而 setState 是一个类型为 SetState 的函数。

注意:这里我们用全小写表示值,首字母大写表示(TypeScript)类型。

对于 JavaScript 而言,上面的数组其实长的都一样,并没有一个有效的途径可以区分彼此。

不过,出于较好的扩展性、可读性和稳定性考虑,我们往往会更偏向于把不同类型的值通过键值对的形式塞到一个对象中,再返回这个对象(尽管这样会增加代码量),而不是使用没有任何限制的数组。比如我们可能会使用如下的对象结构来替换数组:

ts
{
  state,
  setState
}

而 TypeScript 的元组类型正好弥补了这个不足,使得定义包含固定个数元素、每个元素类型未必相同的数组成为可能。(需要注意的是,毕竟 TypeScript 会转译成 JavaScript,所以 TypeScript 的元组无法在运行时约束所谓的“元组”像真正的元组一样,保证元素类型、长度不可变更)。

对于 TypeScript 而言,如下所示的两个元组类型其实并不相同:

ts
[State, SetState]
[SetState, State]

所以添加了不同元组类型注解的数组后,在 TypeScript 静态类型检测层面就变成了两个不相同的元组,如下代码所示:

ts
const x: [State, SetState] = [state, setState];
const y: [SetState, State] = [setState, state];

下面我们还是使用所熟知的 React Hooks 来介绍 TypeScript 元组的应用场景。

比如 useState 的返回值类型是一个元组类型,如下代码所示(以下仅是简单的例子,事实上 useState 的类型定义更为复杂):

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

元组相较对象而言,不仅为我们实现解构赋值提供了极大便利,还减少了不少代码量,这可能也是 React 官方如此设计核心 Hooks 的重要原因之一。

但事实上,许多第三方的 Hooks 往往会出于扩展性、稳定性等考虑,尤其是需要返回的值的个数超过 2 个时,会更偏向于使用对象作为返回值。

这里需要注意:数组类型的值只有显示添加了元组类型注解后(或者使用 as const,声明为只读元组),TypeScript 才会把它当作元组,否则推荐出来的类型就是普通的数组类型

枚举类型

在 JavaScript 原生语言中并没有与枚举匹配的概念,而 TypeScript 中实现了枚举类型(Enums),这就意味着枚举也是 TypeScript 特有的语法(相对于 JavaScript)。

在 TypeScript 中,我们可以使用枚举定义包含被命名的常量的集合,比如 TypeScript 支持数字、字符两种常量值的枚举类型。

我们也可以使用 enum 关键字定义枚举类型,格式是 enum + 枚举名字 + 一对花括弧,花括弧里则是被命名了的常量成员。

表示星期的枚举类型示例,如下代码所示:

ts
  enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }

注意:相对于其他类型,enum 也是一种比较特殊的类型,因为它兼具值和类型于一体,有点类似 class(在定义 class 结构时, 其实我们也自动定义了 class 实例的类型)。

在上述示例中,Day 既可以表示集合,也可以表示集合的类型,所有成员(enum member)的类型都是 Day 的子类型。

前边我们说过,JavaScript 中其实并没有与枚举类型对应的原始实现,而 TypeScript 转译器会把枚举类型转译为一个属性为常量、命名值从 0 开始递增数字映射的对象,在功能层面达到与枚举一致的效果(然而不是所有的特性在 JavaScript 中都有对应的实现)。

下面我们通过如下所示示例看看将如上示例转译为 JavaScript 后的效果。

ts
    var Day = void 0;
    (function (Day) {
        Day[Day["SUNDAY"] = 0] = "SUNDAY";
        Day[Day["MONDAY"] = 1] = "MONDAY";
        Day[Day["TUESDAY"] = 2] = "TUESDAY";
        Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
        Day[Day["THURSDAY"] = 4] = "THURSDAY";
        Day[Day["FRIDAY"] = 5] = "FRIDAY";
        Day[Day["SATURDAY"] = 6] = "SATURDAY";
    })(Day || (Day = {}));

我们可以看到 Day.SUNDAY 被赋予 0 作为值,Day.SATURDAY 被赋予 6 作为值。

在 TypeScript 中,我们可以通过“枚举名字.常量命名”的格式获取枚举集合里的成员,如下代码所示:

ts
  function work(d: Day) {
    switch (d) {
      case Day.SUNDAY:
      case Day.SATURDAY:
        return 'take a rest';
      case Day.MONDAY:
      case Day.TUESDAY:
      case Day.WEDNESDAY:
      case Day.THURSDAY:
      case Day.FRIDAY:
        return 'work hard';
    }
  }

示例中的第 3 行到第 10 行,我们通过 Day.SUNDAY 这样的格式就可以访问枚举的所有成员了。 上面示例中的 work 函数转译为 JavaScript 后,里面的 switch 分支运行时的效果实际上等价于如下所示代码:

ts
...
    switch (d) {
      case 0:
      case 1:
        return 'take a rest';
      case 2:
      case 3:
      case 4:
      case 5:
      case 6:
        return 'work hard';
    }
    ...

这就意味着在 JavaScript 中调用 work 函数时,传递的参数无论是 enum 还是数值,逻辑上将没有区别,当然这也符合 TypeScript 静态类型检测规则,如下代码所示:

ts
work(Day.SUNDAY); // ok
work(0); // ok

这里我们既可以把枚举成员 Day.SUNDAY 作为 work 函数的入参,也可以把数字字面量 0 作为 work 函数的入参。

下面我们就来详细介绍一下 7 种常见的枚举类型:数字类型、字符串类型、异构类型、常量成员和计算(值)成员、枚举成员类型和联合枚举、常量枚举、外部枚举。

数字枚举

从上边示例可知,在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果我们希望枚举值从其他值开始递增,则可以通过“常量命名 = 数值” 的格式显示指定枚举成员的初始值,如下代码所示:

ts
enum Day {
    SUNDAY = 1,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }

在上述示例中,我们指定了从 1 开始递增。

事实上,我们可以给 SUNDAY 指定任意类型(比如整数、负数、小数等)、任意起始的数字,其后未显示指定值的成员会递增加 1。上边的示例转译为 JavaScript 之后,则是一个属性值从 1 开始递增的对象,如下代码所示:

ts
  var Day = void 0;
    (function (MyDay) {
        Day[Day["SUNDAY"] = 1] = "SUNDAY";
        Day[Day["MONDAY"] = 2] = "MONDAY";
        ...
        Day[Day["SATURDAY"] = 7] = "SATURDAY";
    })(Day || (Day = {}));

这里 Day.SUNDAY 被赋予了 1 作为值,Day.SATURDAY 则被赋予了 7 作为值。

当然我们也可以给任意位置的成员指定值,如下所示示例:

ts
  enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY = 5
  }

这里我们给最后一个成员 SATURDAY 指定了初始值 5,但转译后的结果就比较尴尬了,如下代码所示:

ts
  ...
        Day[Day["FRIDAY"] = 5] = "FRIDAY";
        Day[Day["SATURDAY"] = 5] = "SATURDAY";
  ...

我们可以看到 MyDay.FRIDAY 和 MyDay.SATURDAY 的值都是数字 5,这就导致使用 Day 枚举作为 switch 分支条件的函数 work,在接收 MyDay.SATURDAY 作为入参时,也会进入 MyDay.FRIDAY 的分支,从而出现逻辑错误。

这个经验告诉我们,由于枚举默认的值自递增且完全无法保证稳定性,所以给部分数字类型的枚举成员显式指定数值或给函数传递数值而不是枚举类型作为入参都属于不明智的行为,如下代码所示:

ts
  enum Day {
    ...
    SATURDAY = 5 // bad
  } 
  work(5); // bad

此外,常量命名、结构顺序都一致的两个枚举,即便转译为 JavaScript 后,同名成员的值仍然一样(满足恒等 === )。但在 TypeScript 看来,它们不相同、不满足恒等,如下代码所示:

ts
enum MyDay {
    SUNDAY,
    ...
  } 
Day.SUNDAY === MyDay.SUNDAY; // ts(2367) 两个枚举值恒不相等
work(MyDay.SUNDAY); // ts(2345) 'MyDay.SUNDAY' 不能赋予 'Day'

这里的 MyDay 和上边的 Day 看似一样,但是如果我们拿 MyDay 和 Day 的成员进行比较(第 6 行),或者把 MyDay 传值给形参是 Day 类型的 work 函数(第 7 行),就会发现都会提示错误。

不仅仅是数字类型枚举,所有其他枚举都仅和自身兼容,这就消除了由于枚举不稳定性可能造成的风险,所以这是一种极其安全的设计。不过,这可能会使得枚举变得不那么好用,因为不同枚举之间完全不兼容,所以不少 TypeScript 编程人员觉得枚举类型是一种十分鸡肋的类型。而两个结构完全一样的枚举类型如果互相兼容,则会更符合我们的预期,比如说基于 Swagger 自动生成的不同模块中结构相同且描述同一个常量集合的多个同名枚举。

不过,此时我们可能不得不使用类型断言(as)或者重构代码将“相同“的枚举类型抽离为同一个公共的枚举(我们更推荐后者)。

字符串枚举

在 TypeScript 中,我们将定义值是字符串字面量的枚举称之为字符串枚举,字符串枚举转译为 JavaScript 之后也将保持这些值,我们来看下如下所示示例:

ts
 enum Day {
    SUNDAY = 'SUNDAY',
    MONDAY = 'MONDAY',
    ...
  }

这里我们定义了成员 SUNDAY 的值是 'SUNDAY'、MONDAY 的值是 'MONDAY'。

而上述示例转译为 JavaScript 后,Day.SUNDAY 的值依旧是 'SUNDAY',Day.MONDAY 的值依旧是 'MONDAY',如下代码所示:

ts
var Day = void 0;
    (function (Day) {
        Day["SUNDAY"] = "SUNDAY";
        Day["MONDAY"] = "MONDAY";
    })(Day || (Day = {}));

相比于没有明确意义的递增值的数字枚举,字符串枚举的成员在运行和调试阶段,更具备明确的含义和可读性,枚举成员的值就是我们显式指定的字符串字面量。

异构枚举(Heterogeneous enums)

从技术上来讲,TypeScript 支持枚举类型同时拥有数字和字符类型的成员,这样的枚举被称之为异构枚举。

当然,异构枚举也被认为是很“鸡肋”的类型。比如如下示例中,我们定义了成员 SUNDAY 是 'SUNDAY'、MONDAY 是 2,很抱歉,我也不知道这样的枚举能在哪些有用的场合进行使用。

ts
  enum Day {
    SUNDAY = 'SUNDAY',
    MONDAY = 2,
    ...
  }

枚举成员的值既可以是数字、字符串这样的常量,也可以是通过表达式所计算出来的值。这就涉及枚举里成员的一个分类,即常量成员和计算成员。

常量成员和计算(值)成员 在前边示例中,涉及的枚举成员的值都是字符串、数字字面量和未指定初始值从 0 递增数字常量,都被称作常量成员。

另外,在转译时,通过被计算的常量枚举表达式定义值的成员,也被称作常量成员,比如如下几种情况:

  • 引用来自预先定义的常量成员,比如来自当前枚举或其他枚举;
  • 圆括弧 () 包裹的常量枚举表达式;
  • 在常量枚举表达式上应用的一元操作符 +、 -、~ ;
  • 操作常量枚举表达式的二元操作符 +、-、*、/、%、<<、>>、>>>、&、|、^。

除以上这些情况之外,其他都被认为是计算(值)成员。

如下所示示例(援引自官方示例)中,除了 G 是计算成员之外,其他都属于常量成员。

ts
  enum FileAccess {
    // 常量成员
    None,
    Read = 1 << 1,
    Write = 1 << 2,
    ReadWrite = Read | Write,
    // 计算成员
    G = "123".length,
  }

注意:关于常量成员和计算成员的划分其实比较难理解,实际上它们也并没有太大的用处,只是告诉我们通过这些途径可以定义枚举成员的值。因此,我们只需记住缺省值(从 0 递增)、数字字面量、字符串字面量肯定是常量成员就够了。

枚举成员类型和联合枚举

另外,对于不需要计算(值)的常量类型成员,即缺省值(从 0 递增)、数字字面量、字符串字面量这三种情况(这就是为什么我们只需记住这三种情况),被称之为字面量枚举成员。

前面我们提到枚举值和类型是一体的,枚举成员的类型是枚举类型的子类型。

枚举成员和枚举类型之间的关系分两种情况: 如果枚举的成员同时包含字面量和非字面量枚举值,枚举成员的类型就是枚举本身(枚举类型本身也是本身的子类型);如果枚举成员全部是字面量枚举值,则所有枚举成员既是值又是类型,如下代码所示:

ts
  enum Day {
    SUNDAY,
    MONDAY,
  }
  enum MyDay {
    SUNDAY,
    MONDAY = Day.MONDAY
  }
  const mondayIsDay: Day.MONDAY = Day.MONDAY; // ok: 字面量枚举成员既是值,也是类型
  const mondayIsSunday = MyDay.SUNDAY; // ok: 类型是 MyDay,MyDay.SUNDAY 仅仅是值
  const mondayIsMyDay2: MyDay.MONDAY = MyDay.MONDAY; // ts(2535),MyDay 包含非字面量值成员,所以 MyDay.MONDAY 不能作为类型

这里因为 Day 的所有成员都是字面量枚举成员,所以 Day.MONDAY 可以同时作为值和类型使用(第 11 行)。但是 MyDay 的成员 MONDAY 是非字面量枚举成员(但是是常量枚举成员),所以 MyDay.MONDAY 仅能作为值使用(第 12 行 ok,第 13 行提示错误)。

另外,如果枚举仅有一个成员且是字面量成员,那么这个成员的类型等于枚举类型,如下代码所示:

ts
enum Day {
  MONDAY
}
export const mondayIsDay: Day = Day.MONDAY; // ok
export const mondayIsDay1: Day.MONDAY = mondayIsDay as Day; // ok

因为枚举 Day 仅包含一个字面量成员 MONDAY,所以类型 Day 和 Day.MONDAY 可以互相兼容。比如第 4 行和第 5 行,我们既能把 Day.MONDAY 类型赋值给 Day 类型,也能把 Day 类型赋值给 Day.MONDAY 类型。

联合类型使得 TypeScript 可以更清楚地枚举集合里的确切值,从而检测出一些永远不会成立的条件判断(俗称 Dead Code),如下所示示例(援引自官方恒为真的示例):

ts
  enum Day {
    SUNDAY,
    MONDAY,
  }
const work = (x: Day) => {
if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ts(2367)
}
}

在上边示例中,TypeScript 确定 x 的值要么是 Day.SUNDAY,要么是 Day.MONDAY。因为 Day 是纯字面量枚举类型,可以等价地看作联合类型 Day.SUNDAY | Day.MONDAY,所以我们判断出第 7 行的条件语句恒为真,于是提示了一个 ts(2367) 错误。

不过,如果枚举包含需要计算(值)的成员情况就不一样了。如下示例中,TypeScript 不能区分枚举 Day 中的每个成员。因为每个成员类型都是 Day,所以无法判断出第 7 行的条件语句恒为真,也就不会提示一个 ts(2367) 错误。

ts
  enum Day {
    SUNDAY = +'1',
    MONDAY = 'aa'.length,
  }
const work = (x: Day) => {
if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ok
}
}

此外,字面量类型所具有的类型推断、类型缩小的特性,也同样适用于字面量枚举类型,如下代码所示:

ts
 enum Day {
    SUNDAY,
    MONDAY,
  }
  let SUNDAY = Day.SUNDAY; // 类型是 Day
  const SUNDAY2 = Day.SUNDAY; // 类型 Day.SUNDAY
  const work = (x: Day) => {
    if (x === Day.SUNDAY) {
      x; // 类型缩小为 Day.SUNDAY
    }
  }

在上述代码中,我们在第 5 行通过 let 定义了一个未显式声明类型的变量 SUNDAY,TypeScript 可推断其类型是 Day;在第 6 行通过 const 定义了一个未显式声明类型的变量 SUNDAY2,TypeScript 可推断其类型是 Day.SUNDAY;在第 8 行的 if 条件判断中,变量 x 类型也从 Day 缩小为 Day.SUNDAY。

常量枚举(const enums)

枚举的作用在于定义被命名的常量集合,而 TypeScript 提供了一些途径让枚举更加易用,比如常量枚举。

我们可以通过添加 const 修饰符定义常量枚举,常量枚举定义转译为 JavaScript 之后会被移除,并在使用常量枚举成员的地方被替换为相应的内联值,因此常量枚举的成员都必须是常量成员(字面量 + 转译阶段可计算值的表达式),如下代码所示:

ts
const enum Day {
    SUNDAY,
    MONDAY
  }
  const work = (d: Day) => {
    switch (d) {
      case Day.SUNDAY:
        return 'take a rest';
      case Day.MONDAY:
        return 'work hard';
    }
  }
}

这里我们定义了常量枚举 Day,它的成员都是值自递增的常量成员,并且在 work 函数的 switch 分支里引用了 Day。

转译为成 JavaScript 后,Day 枚举的定义就被移除了,work 函数中对 Day 的引用也变成了常量值的引用(第 3 行内联了 0、第 5 行内联了 1),如下代码所示:

ts
    var work = function (d) {
        switch (d) {
            case 0 /* SUNDAY */:
                return 'take a rest';
            case 1 /* MONDAY */:
                return 'work hard';
        }
    };

从以上示例我们可以看到,使用常量枚举不仅能减少转译后的 JavaScript 代码量(因为抹除了枚举定义),还不需要到上级作用域里查找枚举定义(因为直接内联了枚举值字面量)。

因此,通过定义常量枚举,我们可以以清晰、结构化的形式维护相关联的常量集合,比如 switch case分支,使得代码更具可读性和易维护性。而且因为转译后抹除了定义、内联成员值,所以在代码的体积和性能方面并不会比直接内联常量值差。

外部枚举(Ambient enums)

在 TypeScript 中,我们可以通过 declare 描述一个在其他地方已经定义过的变量,如下代码所示:

ts
declare let $: any;
$('#id').addClass('show'); // ok

第 1 行我们使用 declare 描述类型是 any 的外部变量 $,在第 2 行则立即使用 $ ,此时并不会提示一个找不到 $ 变量的错误。

同样,我们也可以使用 declare 描述一个在其他地方已经定义过的枚举类型,通过这种方式定义出来的枚举类型,被称之为外部枚举,如下代码所示:

ts
declare enum Day {
  SUNDAY,
  MONDAY,
}
const work = (x: Day) => {
  if (x === Day.SUNDAY) {
    x; // 类型是 Day
  }
}

这里我们认定在其他地方已经定义了一个 Day 这种结构的枚举,且 work 函数中使用了它。

转译为 JavaScript 之后,外部枚举的定义也会像常量枚举一样被抹除,但是对枚举成员的引用会被保留(第 2 行保留了对 Day.SUNDAY 的引用),如下代码所示:

ts
var work = function (x) {
    if (x === Day.SUNDAY) {
        x;
    }
};

外部枚举和常规枚举的差异在于以下几点:

  • 在外部枚举中,如果没有指定初始值的成员都被当作计算(值)成员,这跟常规枚举恰好相反;
  • 即便外部枚举只包含字面量成员,这些成员的类型也不会是字面量成员类型,自然完全不具备字面量类型的各种特性。

我们可以一起使用 declare 和 const 定义外部常量枚举,使得它转译为 JavaScript 之后仍像常量枚举一样。在抹除枚举定义的同时,我们可以使用内联枚举值替换对枚举成员的引用。

函数类型

函数类型约束即对函数的输入输出进行类型限制,输入即参数,输出即返回值。JavaScript有两种函数定义的方式,分别是函数声明和函数表达式。

ts
//函数声明
function fun1(a:number,b?:number ,...rest:number[]): string{
    return 'fun1';
}

//函数表达式
const fun2:(a:number,b:number) => string = function(a:number,b:number): string{
    return 'fun2';
}

返回值类型

在 JavaScript 中,我们知道一个函数可以没有显式 return,此时函数的返回值应该是 undefined:

ts
function fn() {
  // TODO
}
console.log(fn()); // => undefined

需要注意的是,在 TypeScript 中,如果我们显式声明函数的返回值类型为 undfined,将会得到如下所示的错误提醒。

ts
function fn(): undefined { // ts(2355) A function whose declared type is neither 'void' nor 'any' must return a value
  // TODO
}

我们可以使用类似定义箭头函数的语法来表示函数类型的参数和返回值类型,此时=> 类型仅仅用来定义一个函数类型而不用实现这个函数。

TIP

这里的=>与 ES6 中箭头函数的=>有所不同。TypeScript 函数类型中的=>用来表示函数的定义,其左侧是函数的参数类型,右侧是函数的返回值类型;而 ES6 中的=>是函数的实现。

我们定义了一个函数类型,并且使用箭头函数实现了这个类型。

ts
type Adder = (a: number, b: number) => number; // TypeScript 函数类型定义
const add: Adder = (a, b) => a + b; // ES6 箭头函数

这里请注意:右侧的箭头函数并没有显式声明类型注解

在对象中,除了使用这种声明语法,我们还可以使用类似对象属性的简写语法来声明函数类型的属性,如下代码所示:

ts
interface Entity {
    add: (a: number, b: number) => number;
    del(a: number, b: number): number;
}
const entity: Entity = {
    add: (a, b) => a + b,
    del(a, b) {
      return a - b;
    },
};

在某种意义上来说,这两种形式都是等价的。但是很多时候,我们不必或者不能显式地指明返回值的类型,这就涉及可缺省和可推断的返回值类型的讲解。

可缺省和可推断的返回值类型

幸运的是,函数返回值的类型可以在 TypeScript 中被推断出来,即可缺省。

函数内是一个相对独立的上下文环境,我们可以根据入参对值加工计算,并返回新的值。从类型层面看,我们也可以通过类型推断加工计算入参的类型,并返回新的类型,示例如下:

ts
function computeTypes(one: string, two: number) {
  const nums = [two];
  const strs = [one]
  return {
    nums,
    strs
  } // 返回 { nums: number[]; strs: string[] } 的类型 
}

请记住:这是一个很重要也很有意思的特性,函数返回值的类型推断结合泛型可以实现特别复杂的类型计算(本质是复杂的类型推断,这里称之为计算是为了表明其复杂性),比如 Redux Model 中 State、Reducer、Effect 类型的关联。

一般情况下,TypeScript 中的函数返回值类型是可以缺省和推断出来的,但是有些特例需要我们显式声明返回值类型,比如 Generator 函数的返回值。

Generator 函数的返回值

ES6 中新增的 Generator 函数在 TypeScript 中也有对应的类型定义。

Generator 函数返回的是一个 Iterator 迭代器对象,我们可以使用 Generator 的同名接口泛型或者 Iterator 的同名接口泛型表示返回值的类型(Generator 类型继承了 Iterator 类型),示例如下:

ts
type AnyType = boolean;
type AnyReturnType = string;
type AnyNextType = number;
function *gen(): Generator<AnyType, AnyReturnType, AnyNextType> {
  const nextValue = yield true; // nextValue 类型是 number,yield 后必须是 boolean 类型
  return `${nextValue}`; // 必须返回 string 类型
}

可选参数和默认参数

在实际工作中,我们可能经常碰到函数参数可传可不传的情况,当然 TypeScript 也支持这种函数类型表达,如下代码所示:

ts
function log(x?: string) {
  return x;
}
log(); // => undefined
log('hello world'); // => hello world

在上述代码中,我们在类型标注的:前添加?表示 log 函数的参数 x 就是可缺省的。

也就是说参数 x 的类型可能是 undefined(第 5 行调用 log 时不传入实参)类型或者是 string 类型(第 6 行调用 log 传入 'hello world' 实参),那是不是意味着可缺省和类型是 undefined 等价呢?我们来看看以下的示例:

ts
function log(x?: string) {
  console.log(x);
}
function log1(x: string | undefined) {
  console.log(x);
}
log();
log(undefined);
log1(); // ts(2554) Expected 1 arguments, but got 0
log1(undefined);

答案显而易见:这里的 ?: 表示参数可以缺省、可以不传,也就是说调用函数时,我们可以不显式传入参数。但是,如果我们声明了参数类型为 xxx | undefined,就表示函数参数是不可缺省且类型必须是 xxx 或者 undfined。

因此,在上述代码中,log1 函数如果不显示传入函数的参数,TypeScript 就会报一个 ts(2554) 的错误,即函数需要 1 个参数,但是我们只传入了 0 个参数。

在 ES6 中支持函数默认参数的功能,而 TypeScript 会根据函数的默认参数的类型来推断函数参数的类型,示例如下:

ts
function log(x = 'hello') {
    console.log(x);
}
log(); // => 'hello'
log('hi'); // => 'hi'
log(1); // ts(2345) Argument of type '1' is not assignable to parameter of type 'string | undefined'

在上述示例中,根据函数的默认参数 'hello' ,TypeScript 推断出了 x 的类型为 string | undefined。

当然,对于默认参数,TypeScript 也可以显式声明参数的类型(一般默认参数的类型是参数类型的子集时,我们才需要这么做)。不过,此时的默认参数只起到参数默认值的作用,如下代码所示:

ts
function log1(x: string = 'hello') {
    console.log(x);
}
// ts(2322) Type 'string' is not assignable to type 'number'
function log2(x: number = 'hello') {
    console.log(x);
}
log2();
log2(1);
log2('1'); // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number | undefined'

上例函数 log2 中,我们显式声明了函数参数 x 的类型为 number,表示函数参数 x 的类型可以不传或者是 number 类型。因此,如果我们将默认值设置为字符串类型,编译器就会抛出一个 ts(2322) 的错误。

同理,如果我们将函数的参数传入了字符串类型,编译器也会抛出一个 ts(2345) 的错误。

这里请注意:函数的默认参数类型必须是参数类型的子类型,下面我们看一下如下具体示例:

ts
function log3(x: number | string = 'hello') {
    console.log(x);
}

在上述代码中,函数 log3 的函数参数 x 的类型为可选的联合类型 number | string,但是因为默认参数字符串类型是联合类型 number | string 的子类型,所以 TypeScript 也会检查通过。

剩余参数

在 ES6 中,JavaScript 支持函数参数的剩余参数,它可以把多个参数收集到一个变量中。同样,在TypeScript 中也支持这样的参数类型定义,如下代码所示:

ts
function sum(...nums: number[]) {
    return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2); // => 3
sum(1, 2, 3); // => 6
sum(1, '2'); // ts(2345) Argument of type 'string' is not assignable to parameter of type 'number'

在上述代码中,sum 是一个求和的函数,...nums将函数的所有参数收集到了变量 nums 中,而 nums 的类型应该是 number[],表示所有被求和的参数是数字类型。因此,sum(1, '2') 抛出了一个 ts(2345) 的错误,因为参数 '2' 并不是 number 类型。

如果我们将函数参数 nums 聚合的类型定义为 (number | string)[],如下代码所示:

ts
function sum(...nums: (number | string)[]): number {
    return nums.reduce<number>((a, b) => a + Number(b), 0);
}
sum(1, '2', 3); // 6

那么,函数的每一个参数的类型就是联合类型 number | string,因此 sum(1, '2', 3) 的类型检查也就通过了。

this

众所周知,在 JavaScript 中,函数 this 的指向一直是一个令人头痛的问题。因为 this 的值需要等到函数被调用时才能被确定,更别说通过一些方法还可以改变 this 的指向。也就是说 this 的类型不固定,它取决于执行时的上下文。

但是,使用了 TypeScript 后,我们就不用担心这个问题了。通过指定 this 的类型(严格模式下,必须显式指定 this 的类型),当我们错误使用了 this,TypeScript 就会提示我们,如下代码所示:

ts
function say() {
    console.log(this.name); // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation
}
say();

在上述代码中,如果我们直接调用 say 函数,this 应该指向全局 window 或 global(Node 中)。但是,在 strict 模式下的 TypeScript 中,它会提示 this 的类型是 any,此时就需要我们手动显式指定类型了。

那么,在 TypeScript 中,我们应该如何声明 this 的类型呢?

在 TypeScript 中,我们只需要在函数的第一个参数中声明 this 指代的对象(即函数被调用的方式)即可,比如最简单的作为对象的方法的 this 指向,如下代码所示:

ts
function say(this: Window, name: string) {
    console.log(this.name);
}
window.say = say;
window.say('hi');
const obj = {
    say
};
obj.say('hi'); // ts(2684) The 'this' context of type '{ say: (this: Window, name: string) => void; }' is not assignable to method's 'this' of type 'Window'.

在上述代码中,我们在 window 对象上增加 say 的属性为函数 say。那么调用window.say()时,this 指向即为 window 对象。

调用obj.say()后,此时 TypeScript 检测到 this 的指向不是 window,于是抛出了如下所示的一个 ts(2684) 错误。

ts
say('captain'); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Window'

需要注意的是,如果我们直接调用 say(),this 实际上应该指向全局变量 window,但是因为 TypeScript 无法确定 say 函数被谁调用,所以将 this 的指向默认为 void,也就提示了一个 ts(2684) 错误。

此时,我们可以通过调用 window.say() 来避免这个错误,这也是一个安全的设计。因为在 JavaScript 的严格模式下,全局作用域函数中 this 的指向是 undefined。

同样,定义对象的函数属性时,只要实际调用中 this 的指向与指定的 this 指向不同,TypeScript 就能发现 this 指向的错误,示例代码如下:

ts
interface Person {
    name: string;
    say(this: Person): void;
}
const person: Person = {
    name: 'captain',
    say() {
        console.log(this.name);
    },
};
const fn = person.say;
fn(); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Person'

注意:显式注解函数中的 this 类型,它表面上占据了第一个形参的位置,但并不意味着函数真的多了一个参数,因为 TypeScript 转译为 JavaScript 后,“伪形参” this 会被抹掉,这算是 TypeScript 为数不多的特有语法。

当然,初次接触这个特性时让人费解,这就需要我们把它铭记于心。前边的 say 函数转译为 JavaScript 后,this 就会被抹掉,如下代码所示:

ts
function say(name) {
    console.log(this.name);
}

同样,我们也可以显式限定类函数属性中的 this 类型,TypeScript 也能检查出错误的使用方式,如下代码所示:

ts
class Component {
  onClick(this: Component) {}
}
const component = new Component();
interface UI {
  addClickListener(onClick: (this: void) => void): void;
}
const ui: UI = {
  addClickListener() {}
};
ui.addClickListener(new Component().onClick); // ts(2345)

上面示例中,我们定义的 Component 类的 onClick 函数属性(方法)显式指定了 this 类型是 Component,在第 14 行作为入参传递给 ui 的 addClickListener 方法中,它指定的 this 类型是 void,两个 this 类型不匹配,所以抛出了一个 ts(2345) 错误。

此外,在链式调用风格的库中,使用 this 也可以很方便地表达出其类型,如下代码所示:

ts
class Container {
  private val: number;
  constructor(val: number) {
    this.val = val;
  }
  map(cb: (x: number) => number): this {
    this.val = cb(this.val);
    return this;
  }
  log(): this {
    console.log(this.val);
    return this;
  }
}
const instance = new Container(1)
  .map((x) => x + 1)
  .log() // => 2
  .map((x) => x * 3)
  .log(); // => 6

因为 Container 类中 map、log 等函数属性(方法)未显式指定 this 类型,默认类型是 Container,所以以上方法在被调用时返回的类型也是 Container,this 指向一直是类的实例,它可以一直无限地被链式调用。

函数重载

JavaScript 是一门动态语言,针对同一个函数,它可以有多种不同类型的参数与返回值,这就是函数的多态。

而在 TypeScript 中,也可以相应地表达不同类型的参数和返回值的函数,如下代码所示:

ts
function convert(x: string | number | null): string | number | -1 {
    if (typeof x === 'string') {
        return Number(x);
    }
    if (typeof x === 'number') {
        return String(x);
    }
    return -1;
}
const x1 = convert('1'); // => string | number
const x2 = convert(1); // => string | number
const x3 = convert(null); // => string | number

在上述代码中,我们把 convert 函数的 string 类型的值转换为 number 类型,number 类型转换为 string 类型,而将 null 类型转换为数字 -1。此时, x1、x2、x3 的返回值类型都会被推断成 string | number 。

那么,有没有一种办法可以更精确地描述参数与返回值类型约束关系的函数类型呢?有,这就是函数重载(Function Overload),如下示例中 1~3 行定义了三种各不相同的函数类型列表,并描述了不同的参数类型对应不同的返回值类型,而从第 4 行开始才是函数的实现。

ts
function convert(x: string): number;
function convert(x: number): string;
function convert(x: null): -1;
function convert(x: string | number | null): any {
    if (typeof x === 'string') {
        return Number(x);
    }
    if (typeof x === 'number') {
        return String(x);
    }
    return -1;
}
const x1 = convert('1'); // => number
const x2 = convert(1); // => string
const x3 = convert(null); // -1

注意:函数重载列表的各个成员(即示例中的 1 ~ 3 行)必须是函数实现(即示例中的第 4 行)的子集,例如 “function convert(x: string): number”是“function convert(x: string | number | null): any”的子集。

在 convert 函数被调用时,TypeScript 会从上到下查找函数重载列表中与入参类型匹配的类型,并优先使用第一个匹配的重载定义。因此,我们需要把最精确的函数重载放到前面。例如我们在第 14 行传入了字符串 '1',查找到第 1 行即匹配,而第 15 行传入了数字 1,则查找到第 2 行匹配。

any

any 指的是一个任意类型,它是官方提供的一个选择性绕过静态类型检测的作弊方式。

我们可以对被注解为 any 类型的变量进行任何操作,包括获取事实上并不存在的属性、方法,并且 TypeScript 还无法检测其属性是否存在、类型是否正确。

比如我们可以把任何类型的值赋值给 any 类型的变量,也可以把 any 类型的值赋值给任意类型(除 never 以外)的变量,如下代码所示:

ts
let anything: any = {};
anything.doAnything(); // 不会提示错误
anything = 1; // 不会提示错误
anything = 'x'; // 不会提示错误
let num: number = anything; // 不会提示错误
let str: string = anything; // 不会提示错误

如果我们不想花费过高的成本为复杂的数据添加类型注解,或者已经引入了缺少类型注解的第三方组件库,这时就可以把这些值全部注解为 any 类型,并告诉 TypeScript 选择性地忽略静态类型检测。

尤其是在将一个基于 JavaScript 的应用改造成 TypeScript 的过程中,我们不得不借助 any 来选择性添加和忽略对某些 JavaScript 模块的静态类型检测,直至逐步替换掉所有的 JavaScript。

any 类型会在对象的调用链中进行传导,即所有 any 类型的任意属性的类型都是 any,如下代码所示:

ts
let anything: any = {};
let z = anything.x.y.z; // z 类型是 any,不会提示错误
z(); // 不会提示错误

这里我们需要明白且记住:Any is Hell(Any 是地狱)。

从长远来看,使用 any 绝对是一个坏习惯。如果一个 TypeScript 应用中充满了 any,此时静态类型检测基本起不到任何作用,也就是说与直接使用 JavaScript 没有任何区别。因此,除非有充足的理由,否则我们应该尽量避免使用 any ,并且开启禁用隐式 any 的设置。

unknow

unknown 是 TypeScript 3.0 中添加的一个类型,它主要用来描述类型并不确定的变量。

比如在多个 if else 条件分支场景下,它可以用来接收不同条件下类型各异的返回值的临时变量,如下代码所示:

ts
let result: unknown;
if (x) {
  result = x();
} else if (y) {
  result = y();
} ...

在 3.0 以前的版本中,只有使用 any 才能满足这种动态类型场景。

与 any 不同的是,unknown 在类型上更安全。比如我们可以将任意类型的值赋值给 unknown,但 unknown 类型的值只能赋值给 unknown 或 any,如下代码所示:

ts
let result: unknown;
let num: number = result; // 提示 ts(2322)
let anything: any = result; // 不会提示错误

使用 unknown 后,TypeScript 会对它做类型检测。但是,如果不缩小类型(Type Narrowing),我们对 unknown 执行的任何操作都会出现如下所示错误:

ts
let result: unknown;
result.toFixed(); // 提示 ts(2571)

而所有的类型缩小手段对 unknown 都有效,如下代码所示:

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

void、undefined、null

首先我们来说一下 void 类型,它仅适用于表示没有返回值的函数。即如果该函数没有返回值,那它的类型就是 void。

在 strict 模式下,声明一个 void 类型的变量几乎没有任何实际用处,因为我们不能把 void 类型的变量值再赋值给除了 any 和 unkown 之外的任何类型变量。

而 null 的价值我认为主要体现在接口制定上,它表明对象或属性可能是空值。尤其是在前后端交互的接口,比如 Java Restful、Graphql,任何涉及查询的属性、对象都可能是 null 空对象,如下代码所示:

ts
const userInfo: {
  name: null | string
} = { name: null };

除此之外,undefined 和 null 类型还具备警示意义,它们可以提醒我们针对可能操作这两种(类型)值的情况做容错处理。

我们不建议随意使用非空断言来排除值可能为 null 或 undefined 的情况,因为这样很不安全。

ts
userInfo.id!.toFixed(); // ok,但不建议
userInfo.name!.toLowerCase() // ok,但不建议

而比非空断言更安全、类型守卫更方便的做法是使用单问号(Optional Chain)、双问号(空值合并),我们可以使用它们来保障代码的安全性,如下代码所示:

ts
userInfo.id?.toFixed(); // Optional Chain
const myName = userInfo.name?? `my name is ${info.name}`; // 空值合并

never

never 表示永远不会发生值的类型,这里我们举一个实际的场景进行说明。

首先,我们定义一个统一抛出错误的函数,代码示例如下(圆括号后 : + 类型注解 表示函数返回值的类型):

ts
function ThrowError(msg: string): never {
  throw Error(msg);
}

以上函数因为永远不会有返回值,所以它的返回值类型就是 never。

同样,如果函数代码中是一个死循环,那么这个函数的返回值类型也是 never,如下代码所示。

ts
function InfiniteLoop(): never {
  while (true) {}
}

never 是所有类型的子类型,它可以给所有类型赋值,如下代码所示。

ts
let Unreachable: never = 1; // ts(2322)
Unreachable = 'string'; // ts(2322)
Unreachable = true; // ts(2322)
let num: number = Unreachable; // ok
let str: string = Unreachable; // ok
let bool: boolean = Unreachable; // ok

但是反过来,除了 never 自身以外,其他类型(包括 any 在内的类型)都不能为 never 类型赋值。

在恒为 false 的类型守卫条件判断下,变量的类型将缩小为 never(never 是所有其他类型的子类型,所以是类型缩小为 never,而不是变成 never)。因此,条件判断中的相关操作始终会报无法更正的错误(我们可以把这理解为一种基于静态类型检测的 Dead Code 检测机制),如下代码所示:

ts
const str: string = 'string';
if (typeof str === 'number') {
  str.toLowerCase(); // Property 'toLowerCase' does not exist on type 'never'.ts(2339)
}

基于 never 的特性,我们还可以使用 never 实现一些有意思的功能。比如我们可以把 never 作为接口类型下的属性类型,用来禁止写接口下特定的属性,示例代码如下:

ts
const props: {
  id: number,
  name?: never
} = {
  id: 1
}
props.name = null; // ts(2322))
props.name = 'str'; // ts(2322)
props.name = 1; // ts(2322)

此时,无论我们给 props.name 赋什么类型的值,它都会提示类型错误,实际效果等同于 name 只读 。

联合类型

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。

我们主要通过“|”操作符分隔类型的语法来表示联合类型。这里,我们可以把“|”类比为 JavaScript 中的逻辑或 “||”,只不过前者表示可能的类型。

举个例子,我们封装了一个将 string 或者 number 类型的输入值转换成 '数字 + "px" 格式的函数,如下代码所示:

ts
function formatPX(size: unknown) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}
formatPX(13);
formatPX('13px');

说明:在学习联合类型之前,我们可能免不了使用 any 或 unknown 类型来表示参数的类型(为了让大家养成好习惯,推荐使用 unknown)。

通过这样的方式带来的问题是,在调用 formatPX 时,我们可以传递任意的值,并且可以通过静态类型检测(使用 any 亦如是),但是运行时还是会抛出一个错误,例如:

ts
formatPX(true);
formatPX(null);

这显然不符合我们的预期,因为 size 应该是更明确的,即可能也只可能是 number 或 string 这两种可选类型的类型。

所幸有联合类型,我们可以使用一个更明确表示可能是 number 或 string 的联合类型来注解 size 参数,如下代码所示:

ts
function formatPX(size: number | string) {
  // ...
}
formatPX(13); // ok
formatPX('13px'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型

在第 1 行,我们定义了函数 formatPX 的参数 size 既可以是 number 类型也可以是 string 类型,所以第 5 行和第 6 行传入数字 13 和字符串 '13px' 都正确,但在第 8 行和第 9 行传入布尔类型的 true 或者 null 类型都会提示一个 ts(2345) 错误。

当然,我们可以组合任意个、任意类型来构造更满足我们诉求的类型。比如,我们希望给前边的示例再加一个 unit 参数表示可能单位,这个时候就可以声明一个字符串字面类型组成的联合类型,如下代码所示:

ts
function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  // ...
}
formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)

我们定义了 formatPX 函数的第二个参数 unit,它的类型是由 'px'、'em'、'rem'、'%' 字符串字面类型组成的类型集合。因此,我们可以在第 5 行和第 6 行传入字符串字面量 'em' 和 'rem' 作为第二个实参。如果在第 8 行我们传入一个不在类型集合中的字符串字面量 'bem' ,就会提示一个 ts(2345) 错误。

我们也可以使用类型别名抽离上边的联合类型,然后再将其进一步地联合,如下代码所示:

ts
type ModernUnit = 'vh' | 'vw';
type Unit = 'px' | 'em' | 'rem';
type MessedUp = ModernUnit | Unit; // 类型是 'vh' | 'vw' | 'px' | 'em' | 'rem'

这里我们定义了 ModernUnit 别名表示 'vh' 和 'vw' 这两个字面量类型的组合,且定义了 Unit 别名表示 'px' 和 'em' 和 'rem' 字面量类型组合,同时又定义了 MessedUp 别名表示 ModernUnit 和 Unit 两个类型别名的组合。

我们也可以把接口类型联合起来表示更复杂的结构,如下所示示例

ts
interface Bird {
  fly(): void;
  layEggs(): void;
}
interface Fish {
  swim(): void;
  layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
  return {
   // ...
  } as Bird | Fish;
};
const Pet = getPet();
Pet.layEggs(); // ok
Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性

从上边的示例可以看到,在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。但是,如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫来区分不同的成员类型。

只不过,在这种情况下,我们还需要使用基于 in 操作符判断的类型守卫,如下代码所示:

ts
if (typeof Pet.fly === 'function') { // ts(2339)
  Pet.fly(); // ts(2339)
}
if ('fly' in Pet) {
  Pet.fly(); // ok
}

因为 Pet 的类型既可能是 Bird 也可能是 Fish,这就意味着在第 1 行可能会通过 Fish 类型获取 fly 属性,但 Fish 类型没有 fly 属性定义,所以会提示一个 ts(2339) 错误。

交叉类型

前边我们使用了逻辑或“||” 类比联合类型,那是不是还有一个逻辑与“&&”可以类比类型?

在 TypeScript 中,确实还存在一种类似逻辑与行为的类型——交叉类型(Intersection Type),它可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性。

在 TypeScript 中,我们可以使用“&”操作符来声明交叉类型,如下代码所示:

ts
{
  type Useless = string & number;
}

很显然,如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型,比如既是 string 类型又是 number 类型。因此,在上述的代码中,类型别名 Useless 的类型就是个 never。

合并接口类型

联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型,如下代码所示:

ts
type IntersectionType = { id: number; name: string; } 
    & { age: number };
  const mixed: IntersectionType = {
    id: 1,
    name: 'name',
    age: 18
  }

在上述示例中,我们通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集。

如果合并的多个接口类型存在同名属性会是什么效果呢?

如果同名属性的类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:

ts
 type IntersectionTypeConfict = { id: number; name: string; } 
    & { age: number; name: number; };
  const mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
    age: 2
  };

此时,我们赋予 mixedConflict 任意类型的 name 属性值都会提示类型错误。而如果我们不设置 name 属性,又会提示一个缺少必选的 name 属性的错误。在这种情况下,就意味着上述代码中交叉出来的 IntersectionTypeConfict 类型是一个无用类型。

如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型。

如下所示示例中 name 属性的类型就是数字字面量类型 2,因此,我们不能把任何非 2 之外的值赋予 name 属性。

ts
type IntersectionTypeConfict = { id: number; name: 2; } 
  & { age: number; name: number; };
  let mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ok
    age: 2
  };
  mixedConflict = {
    id: 1,
    name: 22, // '22' 类型不能赋给 '2' 类型
    age: 2
  };

合并联合类型

另外,我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。这里,我们也可以将合并联合类型理解为求交集。

在如下示例中,两个联合类型交叉出来的类型 IntersectionUnion 其实等价于 'em' | 'rem',所以我们只能把 'em' 或者 'rem' 字符串赋值给 IntersectionUnion 类型的变量。

ts
type UnionA = 'px' | 'em' | 'rem' | '%';
type UnionB = 'vh' | 'em' | 'rem' | 'pt';
type IntersectionUnion = UnionA & UnionB;
const intersectionA: IntersectionUnion = 'em'; // ok
const intersectionB: IntersectionUnion = 'rem'; // ok
const intersectionC: IntersectionUnion = 'px'; // ts(2322)
const intersectionD: IntersectionUnion = 'pt'; // ts(2322)

既然是求交集,如果多个联合类型中没有相同的类型成员,交叉出来的类型自然就是 never 了,如下代码所示:

ts
type UnionC = 'em' | 'rem';
type UnionD = 'px' | 'pt';
type IntersectionUnionE = UnionC & UnionD;
const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型

在上述示例中,因为 UnionC 和 UnionD 没有交集,交叉出来的类型 IntersectionUnionE 就是 never,所以我们不能把任何类型的值赋予 IntersectionUnionE 类型的变量。

联合、交叉组合

在前面的示例中,我们把一些联合、交叉类型抽离成了类型别名,再把它作为原子类型进行进一步的联合、交叉。其实,联合、交叉类型本身就可以直接组合使用,这就涉及 |、& 操作符的优先级问题。实际上,联合、交叉运算符不仅在行为上表现一致,还在运算的优先级和 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致 。

联合操作符 | 的优先级低于交叉操作符 &,同样,我们可以通过使用小括弧 () 来调整操作符的优先级。

ts
type UnionIntersectionA = { id: number; } & { name: string; } | { id: string; } & { name: number; }; // 交叉操作符优先级高于联合操作符
type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级

进而,我们也可以把分配率、交换律等基本规则引入类型组合中,然后优化出更简洁、清晰的类型,如下代码所示:

ts
type UnionIntersectionC = ({ id: number; } & { name: string; } | { id: string; }) & { name: number; };
type UnionIntersectionD = { id: number; } & { name: string; } & { name: number; } | { id: string; } & { name: number; }; // 满足分配率
type UnionIntersectionE = ({ id: string; } | { id: number; } & { name: string; }) & { name: number; }; // 满足交换律

在上述代码中,第 2 行是在第 1 行的基础上进行展开,说明 & 满足分配率;第 3 行则是在第 1 行的基础上调整了成员的顺序,说明 | 操作满足交换律。

类型缩减

如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果?效果就是类型缩减成 string 了。

同样,对于 number、boolean也是一样的缩减逻辑,如下所示示例:

ts
type URStr = 'string' | string; // 类型是 string
type URNum = 2 | number; // 类型是 number
type URBoolen = true | boolean; // 类型是 boolean
enum EnumUR {
    ONE,
    TWO
}
type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。

可是这个缩减,却极大地削弱了 IDE 自动提示的能力,如下代码所示:

ts
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。

不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}”即可。

ts
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留

此外,当联合类型的成员是接口类型,如果满足其中一个接口的属性是另外一个接口属性的子集,这个属性也会类型缩减,如下代码所示:

ts
type UnionInterce =
  | {
      age: '1';
    }
  | ({
      age: '1' | '2';
      [key: string]: string;
    });

这里因为 '1' 是 '1' | '2' 的子集,所以 age 的属性变成 '1' | '2':

类型断言(Type Assertion)

TypeScript 类型检测无法做到绝对智能,毕竟程序不能像人一样思考。有时会碰到我们比 TypeScript 更清楚实际类型的情况,比如下面的例子:

ts
const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = arrayNumber.find(num => num > 2); // 提示 ts(2322)

其中,greaterThan2 一定是一个数字(确切地讲是 3),因为 arrayNumber 中明显有大于 2 的成员,但静态类型对运行时的逻辑无能为力。

在 TypeScript 看来,greaterThan2 的类型既可能是数字,也可能是 undefined,所以上面的示例中提示了一个 ts(2322) 错误,此时我们不能把类型 undefined 分配给类型 number。

不过,我们可以使用一种笃定的方式——类型断言(类似仅作用在类型层面的强制类型转换)告诉 TypeScript 按照我们的方式做类型检查。

类型断言有两种,一种是as关键词,另一种的方式是在前面添加<>方式断言

ts
const num1 = res as number;
const num2 = <number>res;

比如,我们可以使用 as 语法做类型断言,如下代码所示:

ts
const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = arrayNumber.find(num => num > 2) as number;

又或者是使用尖括号 + 类型的格式做类型断言,如下代码所示:

ts
const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = <number>arrayNumber.find(num => num > 2);

以上两种方式虽然没有任何区别,但是尖括号格式会与 JSX 产生语法冲突,因此我们更推荐使用 as 语法。

注意:类型断言的操作对象必须满足某些约束关系,否则我们将得到一个 ts(2352) 错误,即从类型“源类型”到类型“目标类型”的转换是错误的,因为这两种类型不能充分重叠。

我们除了可以把特定类型断言成符合约束添加的其他类型之外,还可以使用“字面量值 + as const”语法结构进行常量断言,具体示例如下所示:

ts
/** str 类型是 '"str"' */
let str = 'str' as const;
/** readOnlyArr 类型是 'readonly [0, 1]' */
const readOnlyArr = [0, 1] as const;

类型推断

在 TypeScript 中,类型标注声明是在变量之后(即类型后置),它不像 Java 语言一样,先声明变量的类型,再声明变量的名称。

使用类型标注后置的好处是编译器可以通过代码所在的上下文推导其对应的类型,无须再声明变量类型,具体示例如下:

ts
{
  let x1 = 42; // 推断出 x1 的类型是 number
  let x2: number = x1; // ok
}

在上述代码中,x1 的类型被推断为 number,将变量赋值给 number 类型的变量 x2 后,不会出现任何错误。

在 TypeScript 中,具有初始化值的变量、有默认值的函数参数、函数返回的类型都可以根据上下文推断出来。比如我们能根据 return 语句推断函数返回的类型,如下代码所示:

ts
{
/** 根据参数的类型,推断出返回值的类型也是 number */
function add1(a: number, b: number) {
    return a + b;
}
const x1= add1(1, 1); // 推断出 x1 的类型也是 number
/** 推断参数 b 的类型是数字或者 undefined,返回值的类型也是数字 */
function add2(a: number, b = 1) {
    return a + b;
}
const x2 = add2(1);
const x3 = add2(1, '1'); // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number | undefined
}

在上述 add1 函数中,我们 return 了变量 a + b 的结果,因为 a 和 b 的类型为 number,所以函数返回类型被推断为 number。

当然,拥有默认值的函数参数的类型也能被推断出来。比如上述 add2 函数中,b 参数被推断为 number | undefined 类型,如果我们给 b 参数传入一个字符串类型的值,由于函数参数类型不一致,此时编译器就会抛出一个 ts(2345) 错误。

上下文推断

通过类型推断的例子,我们发现变量的类型可以通过被赋值的值进行推断。除此之外,在某些特定的情况下,我们也可以通过变量所在的上下文环境推断变量的类型,具体示例如下:

ts
{
  type Adder = (a: number, b: number) => number;
  const add: Adder = (a, b) => {
    return a + b;
  }
  const x1 = add(1, 1); // 推断出 x1 类型是 number
  const x2 = add(1, '1');  // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number
}

这里我们定义了一个实现加法功能的函数类型 Adder,声明了add变量的类型为 Adder 并赋值一个匿名箭头函数,箭头函数参数 a 和 b 的类型和返回类型都没有显式声明。

TypeScript 通过add的类型 Adder 反向(通过变量类型推断出值的相关类型)推断出箭头函数参数及返回值的类型,也就是说函数参数 a、b,以及返回类型在这个变量的声明上下文中被确定了。

正是得益于 TypeScript 这种类型推导机制和能力,使得我们无须显式声明,即可直接通过上下文环境推断出变量的类型,也就是说此时类型可缺省。

本站总访问量次,本站总访客数人次
Released under the MIT License.