浅谈 Typescript(二):基础类型和类型的声明、运算、派生

1,082 阅读16分钟

上一篇我们了解到,Typescript 构造了两个相对独立的空间。这篇我们先把目光放在「类型声明空间」的表现,即基础类型和类型的声明与运算。

本文你将看到:

  • Typescript 可以直接拿来用的基础类型
  • Typescript 声明类型的方式和区别,一些核心用法的理解
  • Typescript 类型的运算和派生

本文你不会看到:

  • Typescript 各种语法和API的详细罗列
  • Typescript 配置项的罗列和解释

1 基础类型

DFDCBEC9-3ADE-4A58-921A-51B25117BAC0 2.png

我们先来关注「类型声明空间」的万物之源 —— 基础类型(上图框起来的部分)。「类型声明空间」中的基础类型,就像「变量声明空间」中的 1, 'string', true...。在类型声明空间中,我们既可以直接使用基础类型,也可以通过声明、运算、派生来构造、流转新的类型。

const foo: number = 10	// 直接使用
type bar = number		// 赋值给类型声明

number / string / boolean / null / undefined

「类型声明空间」有五种基础类型用来对应「变量声明空间」中,JS 的五种原始值。

const n: number = 10;
const s: string = 'string';
const b: boolean = true;
const n: null = null;
const u: undefined;

null / undefined 是所有类型的子类型

默认情况下null和undefined是所有类型的子类型。 就是说你可以把 null和undefined赋值给number类型的变量。

你可以给一个有类型的变量赋值同样类型的值,也可以赋值为null / undefined ,他们能单向给任意别的类型赋值。

any 和 unknown

在 TS 中,有两个顶级类型——any 和 unknown,任意类型的变量都可以指定类型为 any 或 unknown。

any

any 「任意」,不仅能指定给任意类型变量,一个any类型变量也能当作任意类型变量使用(比如赋值、调用)。这样,相当于被 any 的变量将彻底丧失类型约束能力。

const foo: any = 1;
const bar: string = foo;	// 可以
foo.a;	// 可以

any 类型在 TypeScript 类型系统中占有特殊的地位。它提供给你一个类型系统的「后门」,TypeScript 将会把类型检查关闭。在类型系统里 any 能够兼容所有的类型(包括它自己)。因此,所有类型都能被赋值给它,它也能被赋值给其他任何类型。

那么什么时候用它呢?当你确定想为一个变量「关闭类型检查」时,比如刚引入一个 JS 库却暂时没精力完整描述它的类型。但与此同时,也埋下了隐患,类型系统在这里是缺失的。

unknown

unknown「未知」,相比「任意」,「未知」强调,必须搞清楚我是谁以后才能用。

let foo: unknown = 1;	// 可以先注解一个“不知道”
foo = 'string';	// 也可以任意赋值,因为在使用前还不知道类型
// 但当你想用的时候,呵呵
foo.length;	// 类型“unknown”上不存在属性“length”
const bar: string = foo;	// 不能将类型“unknown”分配给类型“string”。
// 必须先搞清楚我是谁,通常借助于类型断言
(foo as string).length;	// 可以
const bar: string = (foo as string);	// 可以

反应函数返回的类型 void / never

void

主要用在函数声明中,表示没有返回值的函数的返回值类型。

const VoidFunc = (): void => {};
const returnOfVoidFunc = VoidFunc();	  // void

那我直接声明一个 void 类型会怎么样呢?这是没什么意义的,因为你只能给它赋值为null / undefined。

const aVoid: void = undefined;

never

不返回和没有返回值是两回事。和 void 不同,never 是 TypeScript 中的底层类型,表示那些永不存在的值的类型。

放到函数里说,就是一个不会返回的函数的返回值类型。什么情况下函数会没有返回呢?两种情况:

  1. 函数内有死循环
  2. 函数总抛错
// 1. 函数内有死循环
const NeverFunc = (): never => {
  while(true) {}
};
// 2. 函数总抛错
const NeverFunc = (): never => {
  throw new Error();
};

也可以当类型注解,但只能赋值为另外一个 never,可以放在一段「我认为永远执行不到的代码段」内做「类型保护」(关于类型保护,后面文章会说)。

const neverRetch: never;

数组和元组

数组类型有两种声明方式(其中第二种是「范型」的应用,将在后面介绍)

const arr: number[] = [1,2,3];
const arr: Array<number> = [1,2,3];

如果你确定数组的元素数量和类型(甚至是不同的类型),则可以用更严谨的类型——元组。元组在「变量声明空间」也完完全全是个数组,只是在「类型声明空间」比数组类型更精细。

const status: [ number, string ] = [ 1, '已完成' ];
// 如果你从元组中取某个元素,得到的类型也是正确的
status[0]	// number
// 如果越界了,得到联合类型
status[2]	// number | string

2 类型声明

上一章我们介绍了一些基础类型,基础类型是可以「直接拿用来注解的」,就像我们在 JS 里可以直接console.log(1)一样。但有时候我们需要通过类型声明,在基础类型的基础上,进行类型的别名、组合,和复杂类型的创建,就像 JS 里 const、let、var、function、class 等关键字。

AD1A923F-6937-41EB-BBC6-049E8BBCA2FB 2.png

同样,在类型声明空间中,也可以通过一些关键字声明类型:

  • type:声明一个类型别名
  • interface:声明一个接口
  • class:声明一个类
  • enum:声明一个枚举
  • namespace:声明命名空间
  • module:声明模块

这些关键字不仅在声明产物上更复杂,在运行机制上也有所区别,如下图:

3146DA1C-FF00-447D-A586-E6A7C8D1F555 2.png

  • type、interface 是「纯类型声明」,只在类型声明空间产生声明;其中type 要比 interface 更灵活,可以赋值为基本类型或其他声明产生的类型
  • class 本来是 js(ES)的语法,ts 做了补充,使之能同时产生一个类型声明
  • enum、namespace 是「对变量声明空间有扩展的类型声明」,不但在类型声明空间产生声明,也在变量声明空间构建了特殊的数据结构。要注意两个空间中的声明的关系和区别,不然很容易搞混。

编译行为对类型声明空间的剔除

如图所示,有的声明影响绿色的类型声明空间,而有的“污染”了黄色的变量声明空间。无论如何,JS 在编译后都会把绿色部分剔除掉,而黄色部分转换为可执行的JS结构。两个例子:

/* === 1、type 声明 === */
type t = number
const foo: t = 1
// 编译后
const foo = 1

/* === 2、enum 声明 === */
enum Color {
  Red = 0,
  Green = 1,
  Blue = 2
}
// 编译后
var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

type

type 在类型声明空间中,相当于变量声明空间的 const/let/var,用来声明一个类型别名。

type t = number
const foo: t = 1

你可以在声明等号右侧放任何可以直接注解的东西,比如「原始类型、对象/函数声明、传递其他类型声明、类型运算和派生表达式、类型捕获表达式」,包括“接口”,如下:

type Foo = {
	bar: number;
}

interface

TypeScript的核心原则之一是对值所具有的结构进行类型检查。

那么如何检查一个复杂的值结构呢?interface 可以给::对象、函数::定义类型。可以理解为::「一个可调用类型及其调用模式」::。和 type 一样,interface 并不污染变量声明空间。

接口是对调用模式的描述

对象和函数都是可调用的:对象通过foo[bar]调用属性或方法,函数通过foo(bar)new foo(bar)调用。

// 声明对象 interface
interface Foo {
	bar: number;
}
// 声明函数 interface
interface Foo {
	(bar: number): void;
}
// 声明构造函数 interface
interface Foo {
	new (bar: number): Foo
}

灵活的 interface 声明

此外,interface 提供多种关键词实现灵活的声明语法,主要的几个如下:(具体不展开,参考接口 · TypeScript中文网 · TypeScript——JavaScript的超集

  • 索引类型
  • readonly:只读接口属性
  • extends:接口扩展
  • implements:接口实现

type/直接注解和interface声明的区别

前面说了,我直接搞一个{ bar: number }也能直接注解对象,或者赋值给type,那么type 声明/直接注解的和 interface 声明的有什么区别呢?

interface 可以 merge(重复声明,并合并属性),如下,但 type 不行

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

const foo: Foo = { bar: 'bar', baz: 1 };

参考:TypeScript: Documentation - Everyday Types

class

class 本来是 js的语法,关于它在变量声明空间的用法就不展开了。

class 在类型空间

当你在 ts 中声明一个 class Foo,它会保留 js 中的 class Foo 声明,同时在类型空间声明一个类型 Foo(两空间命名可以重复)。这个类型 Foo 表示的是:::Foo 类的实例类型。::,可以直接当成 interface 来用。

class Foo {
	bar: number;
	baz() {}
}
const foo: Foo = new Foo()

// 等价 interface
interface Foo {
	bar: number;
	baz: () => void;
	// 成员类型既可以是明确注解的,也可以是推测出来的
}

class 实例和 interface 的区别

最重要的一点是,要拎清楚,interface 是纯「类型声明空间」的产物,编译就没了;但 class 可是有实实在在的「变量声明空间」实现,也因此class 可以正常被实例化、继承、调用,interface 不行。

有人说那我 declare 一个 class 当 interface 用不行吗?做人要负责的,既然 declare 了 class,TS 就会认为你真的有 class 实现,如果你还真没有 class 实现(比如从JS引入的),那就是个坑了。

enum

enum 是 ts 创造的一种声明,表示枚举,用法如下面代码:

enum Color {
  Red,
  Green,
  Blue,
}
// 创建了三个 Color 枚举,可以这样引用
const red = Color.Red; // 这里 red 其实会被赋值为 0,枚举的默认行为是以 0,1,2... 类似 index 的方式赋值

显然,enum 已经干扰到变量声明空间的赋值行为了。因此 enum 不仅在类型空间产生声明,也在变量空间构建了特殊结构。下面我们讨论下它在类型声明空间和变量声明空间的表现。

enum 在类型声明空间

上面声明的 Color 在类型空间,相当于声明了一个和枚举类型相同的 Color 类型(这里是 number)

const color: Color = 0;
// 相当于
const color: number = 0;
// 注意是 number,而不是 0|1|2,因此你这样赋值也不会报错
const color: Color = 999;

enum 在变量声明空间

那么如何实现枚举能力呢,编译后可以看到,enum 会在变量空间创建一个「有枚举特性」的 Color 变量:

// 编译后
var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

这段代码创建了这样一个 Color 变量:{0: "Red", 1: "Green", 2: "Blue", Red: 0, Green: 1, Blue: 2}

// 我们可以通过枚举成员拿到它的关联值
const red: Color = Color.Red
// 也可以通过关联值拿到枚举成员名,这在反查的时候非常有意义
const key: string = Color[0]

鸡肋的字符串关联值

为了更语义化,ts 还支持字符串类型关联值:

enum Color {
  Red = 'red',
  Green = 'green',
  Blue = 'blue',
}

我个人不喜欢这么用,因为所谓的语义化并不那么必要,连枚举成员名都解释不清楚的枚举还叫枚举么?更主要的是,用字符串丧失了「反查」的能力:

// 编译后的字符串类型关联值枚举
var Color;
(function (Color) {
    Color["Red"] = "red";
    Color["Green"] = "green";
    Color["Blue"] = "blue";
})(Color || (Color = {}));

也因此,丧失了直接赋值的能力:

const c: Color = 'green';		// 	Type '"green"' is not assignable to type 'Color'.

namespace

js 中,我们通常用一个自执行函数包装出一个「命名空间」,这样 foo 不会污染到外层变量命名空间,而只能通过 something 访问到。

(function(something) {
  something.foo = 123;
})(something || (something = {}));

ts 提供了 namespace(以前叫内部模块),在变量空间封装了这种做法,同时在类型空间提供被包裹的声明方式。

namespace something {
  export const foo: number = 123
	export interface Foo {
		bar: string;
	}
}

namespace 在变量声明空间

很显然,namespace 要侵入变量声明空间。上面声明编译后:

// 和我们自己的包法,一模一样
var something;
(function (something) {
    something.foo = 123;
})(something || (something = {}));
// 可以通过层级访问到内部变量
console.log(something.foo);
// (foo 留着;interface Foo 部分被编译清掉了)

namespace 的更多玩法

除了最简单的包裹变量声明、类型声明,namespace 还可以做到:(参考 命名空间 · TypeScript中文网 · TypeScript——JavaScript的超集

  • 多层嵌套
  • 多文件共同维护一个 namespace 声明
  • 为任意层创建别名

module

为什么说「namespace 以前叫内部模块」呢?因为以前没有 namespace 关键词,命名空间的能力是通过 module 实现的。

module something {
  export const foo: number = 123
}

后来换了更贴切的 namespace,module 也就只用在「外部模块」上了。现在,我们通常只能在 declare 时看到 module 用于补充外部 js 模块的类型。

// path.d.ts
declare module 'path' {
  export const path: any;
}

3 类型运算和派生

对于基础类型、已声明的类型,我们可以通过运算和派生转换为另外一种类型,拿来直接注解或者给类型声明用。

7BBAE44B-EA63-4E20-962D-E74B01407392 2.png

联合类型

在某些场景下,我们需要表示「可能的多种类型」,比如一个fontWeight,值可能是700或者'bold'。在 TS 中是这样表示的:

const fontWeight: string | number = 700;

字面量类型

更具体的,我们可以直接以联合的形式声明字面量类型。随后的赋值只能以被联合的字面量之一,有种乞丐版枚举的意思。

// 字符串字面量
const display: 'flex' | 'block' = 'block';
// 数字字面量
const status: 0 | 1 | 2 | 3 = 2;

联合类型和类型保护

通俗点说,当你用一些逻辑让联合类型注解的变量走到分支,它的联合类型也会缩小范围。

// 比如一个函数
const getStyle = (display: 'flex' | 'block') => {
	if (display === 'flex') {...}
	else {
		// 这里,display 的类型就会缩减到 'block' 上
	}
}

事实上 TS 更智能,甚至能通过两个联合的接口的属性类型来剔除“不可能的部分”。这种特性叫“可辨识联合”,后面会详细介绍。

交叉类型

和联合类型的“或”不同,交叉类型是“且”,把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。比较常见的场景是接口合并,比如:

interface BaseInfo {
	name: string;
	id: number;
}
interface ConnectInfo {
	phone: number;
	mail: string;
}
type PersonInfo = BaseInfo & ConnectInfo;
// 这样,PersonInfo 接口需要同时实现 BaseInfo 和 ConnectInfo 的属性声明

范型

我们回顾前面数组声明的第二种用法:

const arr: Array<number> = [1,2,3];

可以这样理解:我们以 <>的形式为类型Array传入类型number,就得到了一个包装后的“元素类型为 number 的 Array”。就像一个类型工厂,或者类型函数。

但范型更重要的意义是,一旦范型 T(或者别的什么)在入口定义,其内部所有用到 T 的地方都将保持该“可变类型”的一致性。

interface 中的范型

比如我们要封装一个网络请求的返回类型,它外层有通用的 code,然后通过 data 链接到“不可预测”的数据结构上。这时通过传入的范型,就可以获得一个 data 类型很具体的返回类型。

// interface 的范型入口在声明后
interface ResponseType<DataType> {
  code: number,
  data: DataType
}
// 调用
type myResponse = ResponseType<string>	// 获得一个 { code: number; data: string; } 的类型

class 中的范型

比如我们有个 Queue 列,但无法确定队列元素的类型,就可以开放范型。(当然你也可以直接置为 any,就丧失了对元素类型的准确控制)

// class 的范型入口在声明后
class Queue<T> {
  private data: T[] = [];
  push = (item: T) => this.data.push(item);
  pop = (): T | undefined => this.data.shift();
}

// 调用
const queue = new Queue<number>();
queue.push(0);
queue.push('1'); // Error:不能推入一个 `string`,只有 number 类型被允许

函数中的范型

同样是Queue 的函数版,参数和返回都能取到传入的类型。

// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
	return [firstItem];
}
// 调用
getQueue<number>(1)
getQueue<number>('Robin')		// Error

不必传入:范型推论

编译器会根据传入的参数自动地帮助我们确定T的类型

比如上面函数,我直接调用 getQueue,T 作为入参,会被推断为number,并保持整个声明内一致,即返回值为number[]

// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
	return [firstItem];
}
// 调用
getQueue(1)

不要无脑上范型

范型主要用于保持声明内部的一致,不要为了用而用。如果只有一处用到,自然不存在一致的问题,相比用范型,直接用 any 把类型“做掉”更简明。

const foo = <T>(bar: T): void => {}
// 不如这样
const foo = (bar: any): void => {}

索引类型:查询和访问

keyof:索引查询

有时我们需要获取一个 interface 的索引类型(比如解析页面参数的时候),就要用 keyof 关键字。索引类型可能是字面量或者别的,要看 interface 是如何构造的。keyof 相当于把接口的索引归纳到一个类型上。

// interface 以具体 key 声明
interface UrlQuery {
	from: string;
	resourceId: string;
}
// keyof 取出的是字面量类型
type KeyType = keyof UrlQuery;	// KeyType 为 'from' | 'resourceId';

// interface 以可索引声明
interface UrlQuery {
	[q: string]: string;
}
// keyof 取出的是索引类型
type KeyType = keyof UrlQuery;	// KeyType 为 string | number

注意最后一个 case,为什么是 string | number,参考 typescript - Keyof inferring string | number when key is only a string - Stack Overflow

索引访问

在一个已声明的接口或数组上,我们可能需要获取某个接口属性或者数组元素的类型

interface UrlQuery {
	from: string;
	resourceId: string;
}
// 获取接口属性的类型
UrlQuery['from']	// string

type QueueType = string[];
// 获取数组元素的类型
QueueType[0]	// string,给 1、2... 都可以

映射类型

前面的 keyof,是吧接口的索引归纳到一个类型上。在 TS 中,同样有对应的反向操作,即把索引的联合类型映射回来。

type Params = 'from' | 'resourceId';
type UrlQuery = {
	[P in Params]: string;
}

有什么用呢?比如我们实现一个 Partial,也就是把传入接口的所有属性都变为可选的,结合索引和映射:

type Partial<T> = {
	[P in keyof T]?: T[P];
}

注意:interface 不支持

所以我们在例子里只能用 type 声明,在很多时候是一样用的。

Utility Types

如上我们实现的 Partial,其实早已被 TS 内置,此外 TS 还提供了更多工具类型,都是以范型的形式存在的,特别方便于我们在类型声明空间中进行类型的转换。常见的如:

  • Partial、Required、Readonly:用于映射接口属性的可选性
  • Exclude<Type, ExcludedUnion>、Extract<Type, Union>:用于调整接口的属性列表
  • ReturnType:获取函数返回类型

完整列表参考:TypeScript: Documentation - Utility Types


小结

本篇我们主要围绕「TS 在类型声明空间中的行为」展开讨论。

  • TS 在类型声明空间中,像 JS 一样,提供一些基础值、声明和运算语法,基础类型被后两者反复加工,衍生出非常丰富的类型。
  • 基础类型主要针对 JS 的几种基础值,做对应的注解
  • 类型声明可以自定义类型名称,声明丰富的类型结构,有的甚至能干扰到「变量声明空间」
  • 各种运算和派生语法,能把一种或几种类型转换为其他类型,进一步增加了「类型声明空间」的丰富程度

下一篇,我们将连接对岸的「变量声明空间」,看TS如何通过类型和JS交流?