Skip to main content

TypeScript 果然够 Type

wKevin

从 C、Python 等传统语言走过来的对类型都有“深刻”的理解,去掉枚举、列表、元祖、集合等组合型的,基础类型我整理了下表:

类型CPythonGotypescript
整数int
short
long
int
long
int[x]
uint[x]
rune
number
浮点数float
double
floatfloat32
float64
number
复数_complexcomplexcomplex64
complex128
number
字符charbyte
字符串str
unicode
stringstring
布尔_boolbooleanboolboolean
指针*uintptr
任意类型any
unknown
无类型voidNoneTypevoid
never
特色类型字面量类型

ts 比其他语言在某些方面好像简化了,比如用 number 代替各种数字型,但某些方面又好像复杂了很多,任意类型和无类型就弄出来 4 种,还搞出来 字面量类型 —— 这些变化,体现了 ts 语言设计者的权衡和用心,同时语言设计者也为用户扩展 ts 的 type 留下了一些语法,更是在 type 自定义、扩展这个方面与其他语言拉开了差异。

下面,从我一个刚入门不久的 ts 用户经历和感受,聊聊我最近对 TypeScript 中 Type 这几个字的理解。

类型名是否添加

python 和 go 在代码中类型名是可加可不加的,大部分情况下都不需要用户自己加,而是编译器自己推导,而 ts 走了 C 的路线,十分甚至强制用户指明所有类型。

比如定义变量:

  • Python: i = 10i: int = 10
  • Go: i := 10i int := 10
  • C :int i = 10
  • ts: const i:number = 10

从定义方便性上看,python 和 go 都替用户节省了键盘敲击,ts 学习了几十年前的老大哥 C,决定毁掉用户键盘。

why?—— 不是我今天详聊的内容,略过。

ts 中的任意类型:any、unknown 及变量 undefined

  • any:任意类型的变量
  • unknown: 表示未知类型,unknown 与 any 类似 但使用前必须进行断言或守卫
  • undefined: 是个内建变量,不是类型

any 和 unknown 的相同点是:变量可以被赋值任何类型的值

let anyTypeVar: any;
anyTypeVar = "foobar";
anyTypeVar = 10;
anyTypeVar = { foobar: 10 };

let unknownTypeVar: unknown;
unknownTypeVar = "foobar";
unknownTypeVar = 10;
unknownTypeVar = { foobar: 10 };

any 和 unknown 的不同点是:any 变量可以向其他类型变量赋值,unkonwn 却只能向 any 和 unknown 变量赋值。

let i: number = 10;
i = anyTypeVar; // OK,并且 i 不再是 number 类型,而是 any 类型
// i = unknownTypeVar; // Error,语法不允许
anyTypeVar = unknownTypeVar; // OK

所以,any 可以理解成双向的,能进能出;unknown 是个单向的,出口只能内循环。

ts 中的空虚类型:never、void 及变量 null

  • never: 永不存在的值的类型
  • void: 无任何类型,没有类型,常用于表示函数没有返回值
  • null: 是个内建变量,不是类型

never 比较特殊,可以做函数返回值,但只能放在不能正常结束的函数里面,比如:死循环、抛异常……

function loopForever(msg: string): never {
while (true) {}
}
function error(msg: string): never {
throw new Error(msg);
}

never 也可以定义变量,但任何类型都不能赋值给 never 类型(除了 never 本身之外,any 也不行)。

void 常用与函数返回值

function foobar(): void {
console.log("hello world");
}

相比不写 void 做返回值,好处是一旦这个函数哪天被某个程序员不小心写了个返回值,是会报错的。

void 类型的变量只能赋值 undefined、null、和 void

let v1: void;
// v1 = 10 // Error
v1 = null;
v1 = undefined;

let v2: void;
v1 = v2;

ts 类型之于防御型和契约型

防御型:即无法限制接收的数据类型、数据量时,只能自己内部做好各级防范措施的业务场景,求人不如求己型。—— 将入参声明为 any、unknown 可以做到,并且节省函数重载等方式带来的复杂性。

比如:使用 typeof(类型保护)和 as(类型断言)。

举例:函数入参不做限制,但 string、number 不同类型做不同处理。

function foobar(notSure: unknown) {
if (typeof notSure === "string") {
console.log((notSure as string).toLowerCase());
}
if (typeof notSure === "number") {
console.log(notSure);
}
}
foobar("ABC"); // abc
foobar(10); // 10

契约型:即双方严格遵守 API 定义的业务场景,白纸黑字、签字画押型。 —— 细致、明确的 API 才能支撑这种业务场景,口头和文档都约束不了 100% 的,只有语言级别的才能彻底解决。

比如上面例子中去掉 unknown,指定的更详细些:

function foobar(notSure: string|number): void {
...
}

如果契约是这么制定的:foobar 的返回值也要是 string | number,可以

function foobar(notSure: string|number): string|number {
...
}

或者用泛型

function foobar<T>(notSure: T): T {
...
}
foobar<string>('ABC')
foobar<number>(10)
foobar<boolean>(true) // 也是允许的,怎么避免?

不希望的 boolean 也是可以的,怎么避免?

function foobar<T extends string|number>(notSure: T): T {
...
}
foobar<string>('ABC')
foobar<number>(10)
// foobar<boolean>(true) // Error

契约好像能够描述的更清晰和确定了,不错。C、Python 具备这样的契约描述能力么?—— 我觉得暂时还不能,尤其是我们继续看下面越来越复杂的契约。

自定义类型的基础概念

字面量类型

const a: number = 10;
const b: 10 = 10;
const c: "foo" = "foo";
const d: true = true;

与第 1 行相比,后面 3 行显得是不是有病?10、'foo'、true 都是值,怎么放在类型的位置上?—— 这就是字面量类型(Literal Types),官方文档在这里

字面量类型的变量只能有 1 个值,并且只能是字面量类型相同的值,这似乎让人感觉很鸡肋,但看看下面例子:

function printText_Literal(s: string, alignment: "left" | "right" | "center") {
// ...
}
// 或者
type Align = "left" | "right" | "center";
function printText_Literal(s: string, alignment: Align) {
// ...
}

两种表达方式效果相同,Align 是 3 个字面量类型的 Union 联合类型,快速达到限制入参的目的。当然,你也可以使用枚举达到限制作用:

enum Align {
left,
right,
center,
}
function printText_Enum(s: string, alignment: Align) {
// ...
}

字面量 Union 和枚举都可以达到限制入参类型,规范 API 的作用,但站在使用者的角度:字面量 Union 方式:用户不需要 import 更多的类型,而枚举方式用户必须 import Align 枚举才能使用:

import {printText_Literal} from ...
printText_Literal("Hello, world", 'left');

import {printText_Enum, Align} from ...
printText_Enum("Hello, world", Align.left);

似乎仍是难分优劣,确实在同一个 APP 中几乎难分伯仲,但放在前后端开发,你的 API 是被远程调用的,或者你是通过 RPC 远程调用他人的 API,如:

const req = { url: "https://baiu.com", method: "GET" };
handleRequest(req.url, req.method); // method 只能是字符串,并且有传错(拼写错误)字符串的几率

此时,枚举一下就无用武之地了。

子类型(extends)

第一次看 ts 文档时,也是想当然的以为 extends 意为继承,但其实在 ts 中,尤其是 ts 的 type 定义及相关表达式中,extends 是子类型(subtyping)的含义。

subtype 子类型对应而是 supertype 超类型,即:subtype extends supertype,通常也写作 subtype <: supertype,这样任何需要使用 supertype 类型对象的环境中,都可以安全地使用 subtype 类型的对象。

字面量类型分 2 种:数字字面量、字符串字面量、布尔字面量等,他们分别是 number、string、boolean 的 subtype。

type FOO = "hello";
// FOO <: string <: unknown

type BAR = 10;
// BAR <: number <: unknown

条件类型

extends + 条件表达式(三目运算符) —— ts 中称之为条件类型,它为自定义类型增加了灵活性。

比如:

type Exclude<T, U> = T extends U ? never : T;

如果 T 是 U 的子类型,则丢弃,否则保留,拿 interface 做个实验

interface IPerson {
name: string;
age: number;
sex: 0 | 1;
}

interface IMan {
name: string;
age: number;
}

type Man1 = Exclude<IPerson, IMan>; // Man = never
type Man2 = Exclude<IMan, IPerson>; // Man = IMan

从上面这个例子很难理解 Exclude 排除的含义,换个 union 的例子就明白了—— 从 T 中去除 U 中存在的元素

type P = Exclude<"red", "red" | "green" | "blue">; // P = never
type Q = Exclude<"red" | "pink", "red" | "green" | "blue">; // Q = "pink"

type Exclude<T, U> = T extends U ? never : T; 中的 T extends U 针对 Union 会自动排列,分别进行,即分别判断:

  • red extends red ? never : red
  • red extends green ? never : red
  • red extends blue ? never : red
  • pink extends red ? never : pink
  • pink extends green ? never : pink
  • pink extends blue ? never : pink

最终得到 Q = "pink"

ts 内建自定义类型

下面命令可以查看 ts 内建了哪些 type:

$ ls node_modules/typescript/lib/lib.es*

常用的几类基本都在 es5 标准实现的文件中了,es2015、es2020 也有一些,但我感觉用的人不多。

$ ls node_modules/typescript/lib/lib.es5.d.ts | xargs -I{} sh -c 'cat {} | grep -e "^\ *type" '
type Awaited<T> =
type Partial<T> = {
type Required<T> = {
type Readonly<T> = {
type Pick<T, K extends keyof T> = {
type Record<K extends keyof any, T> = {
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type NonNullable<T> = T extends null | undefined ? never : T;
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;
type ArrayBufferLike = ArrayBufferTypes[keyof ArrayBufferTypes];

我把他们分成几类

拿其中几个举举例。

Partial

声明是:

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

猛一看好像没啥用,把一个类型的 key 取出来,再组合成 {key: value},折腾一圈回原样啊?

No! 别漏了 ?:,并不是回原样,而是变成可选属性。

type Color = {
red: number
green: number
blue: number
}
type PColor = Partial<Color>
// type PColor = {
// red?: number; // 全部变成 ?:
// green?: number;
// blue?: number;
// }

并且还能处理 interface:

interface Color  {
red: number
green: number
blue: number
}
type PColor = Partial<Color> // 结果同上

Record

声明是:

type Record<K extends keyof any, T> = { [P in K]: T };

可以用 K 做 key type,T 做 value type,创建新的 type —— 即组合出一个 object 的 type。使用起来如下:

type R = Record<"red" | "green" | "blue", number>;
const r: R = {
red: 1,
green: 2,
blue: 3,
};

再次强调:"red" | "green" | "blue" 是个 type,Union 是个 type,不是值。

ReturnType

声明是:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

T 是函数类型 (...args: any) => any 的子类型,即对入参和返回值都没有限制,反正就是个函数,入参返回值都可以是 any,即可以没有。

infer 是 ts 的占位符语法,所占位置表示返回值的类型,用 R 代表。

ReturnType 被定义为 T 若是有返回值的函数,ReturnType 就是返回值的类型,否则是 any。

看一个实例,摘自 Midway 源码。

async function createFunctionApp<
T extends IMidwayFramework<any, any, any, any, any>,
Y = ReturnType<T["getApplication"]>
>(baseDir: string = process.cwd(), options?): Promise<Y> {
// ......
return appManager.getApplication(MidwayFrameworkType.SERVERLESS_APP);
}
  • T 是 IMidwayFramework<any, any, any, any, any> 的子类型,这是个模块,包含多个函数
  • ReturnType<T["getApplication"]> 试图将 T 模块中的 getApplication 函数的返回值类型提取出来,赋给 Y
  • createFunctionApp 函数 2 个入参:baseDiroptions,1 个返回值:Promise<Y>
  • 函数的实现最终返回的就是 getApplication(),与 Promise<Y> 呼应。

这样,在 getApplication() 返回值会变化(版本升级、API 变化)的情况下,本函数就不需要变化了,全部都泛型化了。

Lowercase/Uppercase

不知道你是否和我有类似的感觉,总觉得这是个函数,但是它是个类型,有啥用呢?再从 Midway 中找一段代码,看看高手咋用的。

interface Convertable {
[key: string]: string | number;
}

// T 是 Convertable 接口的子类型
// Group 是 string 字面量类型 —— 再次提醒:泛型里只能是类型,不能是 string 变量
type ConvertString<T extends Convertable, Group extends string> = {
[P in keyof T]: P extends string // 遍历 T 的 key
? T[P] extends number // 如果是字符串,继续判断 T[P],T[P] 即 T 的 value
? `${Uppercase<Group>}_${T[P]}` // 如果 value 是 number 型,用 Uppercase 和 value 组合出新的类型
: never // 如果 value 不是 number,无类型
: never; // 如果 key 不是字符,无类型
};

// 全局变量
const codeGroup = new Set();

// 这是一个错误注册函数:
// 注册错误组(string)和错误码({key:value} 对象)到 codeGroup 变量中,约束为:
// - 错误组只能注册一次,不能重复
// - 返回值是“类似”枚举的对象,对错误码的加工:value 转换成 `{key: "大写(错误组_错误码value)"}`
function registerErrorCode<T extends Convertable, G extends string>(
errorGroup: G,
errorCodeMapping: T
): ConvertString<T, G> { // 通过 ConvertString 对返回值进行了详细、明确的契约定义
if (codeGroup.has(errorGroup)) {
throw new Error(
`Error group ${errorGroup} is duplicated, please check before adding.`
);
} else {
codeGroup.add(errorGroup);
}
const newCodeEnum = {} as Convertable;
for (const errKey in errorCodeMapping) {
newCodeEnum[errKey as string] =
errorGroup.toUpperCase() +
"_" +
String(errorCodeMapping[errKey]).toUpperCase();
}
return newCodeEnum as ConvertString<T, G>;
}

上面代码通过 ConvertString 类型,对返回值进行了灵活、详细、明确的契约定义,从而可以成为一个独立的模块,它不做任何依赖,甚至你可以用它申请一个 npm module。

const c: Convertable = {
foo: "hello",
bar: "world",
};
const convertString = registerErrorCode<Convertable, "myerror">("myerror", c);
console.log(convertString); // { foo: 'MYERROR_HELLO', bar: 'MYERROR_WORLD' }

const convertString2 = registerErrorCode("custom", c); // <T, G> 也是可以不写的
console.log(convertString2); // { foo: 'MYERROR_HELLO', bar: 'MYERROR_WORLD' }

const FrameworkErrorEnum = registerErrorCode("midway", {
UNKNOWN: 10000,
COMMON: 10001,
} as const);
console.log(FrameworkErrorEnum); // { UNKNOWN: 'MIDWAY_10000', COMMON: 'MIDWAY_10001' }

使用起来也挺简单、方便,可以灵活的分组注册自己的错误码。

自定义 type 仿佛打开了一个编程的新领域,以前写代码都是定义变量、实现函数、组合类和接口——三板斧,但 ts 突然多出来一个工作:用 type 定义 API 契约,甚至起到了部分取代文档的地步,令我直呼过瘾。

大神们肯定有更神来之笔,例如下面的开源项目。

sindresorhus/type-fest

前段时间偶然看到一篇掘金:来做做这 48 道 TypeScript 练习题,试试你的 TS 学得怎么样了!(附答案解析),赞叹的不行!

深挖一下,来自阿宝哥的一个开源项目 awesome-typescript,他创建了 40+ 个 issue,讨论了 40+ 个自定义 type。

再深挖一下,这些 type 都来自一个 7.5k start 的开源项目 type-fest

研究这些 type 我适应了很久,总觉得这不是函数应该干的事情么?为啥不放在三板斧里实现?……但是人家就要放在 type 里玩,有些是玩票性质的,有些也是挺有用的,下面我列几个点评一下。

如果你也想研究:

$ yarn add type-fest
$ code node_modules/type-fest

Simplify

声明:

export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] };

这不是和内建的 Partial 一个意思么?只不过不是 ?: —— Yeah,就是这么简单,作者高兴你咋地。作者还给了 2 个简单的例子,感受一下:

作用 1:多个 type 取并集。

import { Simplify } from "type-fest";

type PositionProps = {
top: number;
left: number;
};

type SizeProps = {
width: number;
height: number;
};

type Props = Simplify<PositionProps & SizeProps>;
//得到:
// type Props = {
// top: number;
// left: number;
// width: number;
// height: number;
// }

作用 2:将 interface 改成 type。

interface SomeInterface {
foo: number;
bar?: string;
baz: number | undefined;
}

function fn(object: Record<string, unknown>): void {}

const someInterface: SomeInterface = { foo: 123, bar: "hello", baz: 456 };
fn(someInterface); // Error:Index signature is missing in type 'SomeInterface'.,因为 interface 可以 re-open。
fn(someInterface as Simplify<SomeInterface>); // OK,interface 强制转换成 type

当你想调用一个 type 类型的 API 函数时,但入参是个 interface 类型的变量时,就可以用 Simplify 试试了!

IsEqual

声明:

export type IsEqual<T, U> =
(<G>() => G extends T ? 1 : 2) extends
(<G>() => G extends U ? 1 : 2)
? true
: false;

说实话,没看懂,两个 type 又不是变量,type 的相等如何理解呢?

Filter

声明:

type Filter<KeyType, ExcludeType> = IsEqual<KeyType, ExcludeType> extends true ? never : (KeyType extends ExcludeType ? never : KeyType);
------------------------------------------- ----- -----------------------------------------------
---------------------------- ----- --------

如果 KeyType 与 ExcludeType 相等或是其子类型,则返回 never,否则返回 KeyType。即 ExcludeType 像是一个 mask(掩码)或 Filter(过滤器)。

使用:

type A = Filter<string, string>                     // A = never
type B = Filter<string, number> // B = string
type C = Filter<string|number, number> // C = string
type D = Filter<string|number, string|number> // D = never
type E = Filter<'red', 'red'|'green'|'blue'> // E = never
type F = Filter<'red'|'pink', 'red'|'green'|'blue'> // F = 'pink'

Except

声明:

export type Except<ObjectType, KeysType extends keyof ObjectType> = {
[KeyType in keyof ObjectType as Filter<KeyType, KeysType>]: ObjectType[KeyType];
};
  • 2 个输入泛型:ObjectType、KeysType
  • KeysType 是 keyof ObjectType 的子类型,union 的 subtype 比 supertype 要少的,比如 'a'|'b'"a"|"b"|"c" 的子类型。这一点不像 interface,越是 subtype 元素数量越多。
  • Filter<KeyType, KeysType>] 可以过滤掉 ObjectType、KeysType 都存在的 key
  • 剩下多出来的 key,组合出新的 type

所以最终结果是用 KeysType 做 mask(掩码)或 Filter(过滤器),筛掉 ObjectType 中的某些属性。

type Foo = {
a: number;
b: string;
c: boolean;
};

type FooWithoutA = Except<Foo, 'a' | 'c'>; // {b: string};

Except 与 Filter 的区别是:Filter 接收的是独立类型或 Union,Except 接收的是 Object 对象类型。

SetOptional

声明:

export type SetOptional<T, Keys extends keyof T> = Simplify< Except<T, Keys> &  Partial<Pick<T, Keys>>  >;

哎呀,越来越复杂了!

Pick、Partial 是 ts 内建 type,Except、Simplify 是本开源项目新增的,组合在一起看能不能认出来。

我来拆解一下:

Simplify<  Except<T, Keys>             &   Partial<Pick<T, Keys>>                    >
---对象 T 过滤指定 Keys--- ---选出 T 中 keys 属性,并修改成可选的---

然后在通过 Simplify 将筛掉 Keys 的部分和保留 Keys 并修改成可选属性的部分结合到一块,最后即:把 keys 指定的部分修改成可选。

示例:

type Foo = {
a: number;
b?: string;
c: boolean;
}

type SomeOptional = SetOptional<Foo, 'b' | 'c'>; // 将 b、c 变成可选属性
// type SomeOptional = {
// a: number;
// b?: string; // b 本来就是可选属性,保持
// c?: boolean; // c 由非可选,变成可选属性
// }

作者大概定义了 60+ 这样的 type,依然可以分成这么几类:

  • 对 type/interface 进行筛选和加工
  • 对 object 进行筛选和加工
  • 对 string 字面量 type 进行字符串处理
  • 对 function 而入参、返回值等加工或提取
  • 限制变量赋值的范围(如仅能:空对象、或指定对象)

最后

ts 将自定义 type 像函数一样玩,相比 C、Python、Go……放佛多出来一块战场,让 TypeScript 中的 Type 显的含义丰富、熠熠生辉。

这一切,除了让开发者写代码时实时探测类型正确性、完整性,还能够让 TypeScript 的 API 起到文档一样的存在,让 TDD 模式更便捷,让 API 的使用者和开发者更解耦,API 制定好,各自分头做就是了,没有模棱两可的地方给你将来互相扯皮。

契约型编程没有这种语言级别的支撑,真的可能落入口头和空谈。