TypeScript 果然够 Type
从 C、Python 等传统语言走过来的对类型都有“深刻”的理解,去掉枚举、列表、元祖、集合等组合型的,基础类型我整理了下表:
类型 | C | Python | Go | typescript |
---|---|---|---|---|
整数 | int short long | int long | int[x] uint[x] rune | number |
浮点数 | float double | float | float32 float64 | number |
复数 | _complex | complex | complex64 complex128 | number |
字符 | char | byte | ||
字符串 | str unicode | string | string | |
布尔 | _bool | boolean | bool | boolean |
指针 | * | uintptr | ||
任意类型 | any unknown | |||
无类型 | void | NoneType | void never | |
特色类型 | 字面量类型 |
ts 比其他语言在某些方面好像简化了,比如用 number 代替各种数字型,但某些方面又好像复杂了很多,任意类型和无类型就弄出来 4 种,还搞出来 字面量类型 —— 这些变化,体现了 ts 语言设计者的权衡和用心,同时语言设计者也为用户扩展 ts 的 type 留下了一些语法,更是在 type 自定义、扩展这个方面与其他语言拉开了差异。
下面,从我一个刚入门不久的 ts 用户经历和感受,聊聊我最近对 TypeScript 中 Type 这几个字的理解。
类型名是否添加
python 和 go 在代码中类型名是可加可不加的,大部分情况下都不需要用户自己加,而是编译器自己推导,而 ts 走了 C 的路线,十分甚至强制用户指明所有类型。
比如定义变量:
- Python:
i = 10
或i: int = 10
- Go:
i := 10
或i 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
extendsred
? never :red
red
extendsgreen
? never :red
red
extendsblue
? never :red
pink
extendsred
? never :pink
pink
extendsgreen
? never :pink
pink
extendsblue
? 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 函数的返回值类型提取出来,赋给 YcreateFunctionApp
函数 2 个入参:baseDir
、options
,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 制定好,各自分头做就是了,没有模棱两可的地方给你将来互相扯皮。
契约型编程没有这种语言级别的支撑,真的可能落入口头和空谈。