阅读 625

构造类型抽象、TypeScript 编程内参(二)

本文是《TypeScript 编程内参》系列第二篇:构造类型抽象,主要记述 TypeScript 的高级使用方法和构造类型抽象。

PS: 本文语境下的「约束」指的是「类型对值的约束」

一、构造类型抽象

在 TS 的世界里,总有「动态地生成类型」的需求,比如下面的 UserWithHisBlogsUser 重复的部分:

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

type UserWithHisBlogs = {
    id: number;
    name: string;
    blogs: Blog[]
}

type Blog = {
    id: number;
    content: string;
    title: string;
}
复制代码

上面的类型定义是存在冗余的,当 User 上面写了新的字段,我们就不得不手工的去改 UserWithHisBlogs 以使其拥有刚刚新增的字段。

那么,有没有什么抽象的方法避免这个问题呢?有的, 利用 &:

type UserWithHisBlogs = User & {
    blogs: Blog[];
}
复制代码

这里后文会解释 & 的含义,这里的 UserWithHisBlogs 跟一开始的例子里的类型完全等价,唯一的区别是利用 & 关联了 User,避免了上面那个重复修改的问题。


这里只是个简单的引子,抽象的意义在于减少重复的事情,类型抽象的意义在于减少冗余的类型说明(减少重复的类型说明)

在实际 TS 编程的时候应该特别注意:通过构造类型抽象,尽量复用原有的类型声明,避免重复声明。

二、构造数/元组类型

我们可以这样声明数组的类型:

type Arr = Array<any>;
// 这样也可以,跟上面几乎是等价的
type Arr = any[];
复制代码

但是这样声明的数组元素类型都是一样的,很多情况下我们代码里面的数组里不同位置的元素的类型是不一样的,因此有了元组这样的类型抽象去约束数组元素:

type NumStr = [number, string];
// 第三个元素不在 NumStr 里,会报错
const pair: NumStr = [1, '1', 'xxx'];

// 可以嵌套声明
type N = [[number, string], [number, string]]
const n: N = [[1, '1'], [2, '2']];
复制代码

三、构造联合/交叉类型

ts 的类型是可以计算的,通过不同的运算符连接不同的类型可以得出不同的类型。


联合类型 Uinion Type 通常由 | 运算符连接两个类型得出来的,如 A | B 的意思是要么满足 A 的约束,要么满足 B 的约束 (满足一个即可)

可以参考下面的例子:

type Suffix = '先生' | '女士';
const sayName = (name: string, suffix: Suffix) => {
    return name + ' ' + suffix;
}
sayName('e', '先生');
sayName('c', '女士');
sayName('z', '老师'); // 报错
复制代码

用约束的视角来看待类型计算会容易很多

交叉类型 Intersection Type 通常由 & 运算符连接两个类型得出来的,如 A & B 的意思是既要满足 A 的约束,也要满足 B 的约束 (同时满足)

实例参考:

type Admin = { permission: 100 };
type User = { permission: number, name: string };

function systemReboot(user: User & Admin) {
    if (user.permission < 100) {
        throw new Error('systemReboot error: permission deny');
    } else {
        /** $ sudo reboot **/
    }
}

systemReboot({
    permission: 1, // 这里不满足 Admin 的约束 报错哦
    name: '普通用户'
});

systemReboot({
    permission: 100, // 可以 ~
    name: '管理员用户'
});

// 有了交叉类型我们便不必定义 AdminUser
type AdminUser = { permission: 100, name: string  };
// 取而代之的是 (避免了重复声明、尽可能的利用现有元素来构造新的类型)
type AdminUser = Admin & User;
复制代码

看完上面的例子,估计很多人都会想到,能不能定义偶数这种类型?以目前 ts 的能力来看,现在还不具备基本类型的动态拆解能力,或许未来会有,但是 ts 现在可以做到对象的动态拆解/抽象哦,后文会详细描述。

四、构造 never 类型

了解联合和交叉类型后,聪明的你也许已经发现了类似这样的类型表达式:

type WTF = 'A' & 'B';
复制代码

既是字符串 'A' 又是字符串 'B' 的「薛定谔的值」?显然,js 里是不存在这样的值的

通过 VSCode 我们可以看到这里的 WTF 类型是 never 其含义是 any 的对立面,即「什么值都不兼容」或者「没有」:

  1. any: 无约束 => 什么值都可以兼容
  2. never: 无穷强的约束 => 什么值都不兼容

以下是对「什么值都不兼容」的代码说明

let n: never;
n = e;
n = 'e'
n = {};
n = () => {};
n = n;
// never 跟任何类型都不兼容,除了它本身
复制代码

💡💡💡 never 并非一无是处,在后文的一些高级用法里 never 很常见。


关注【IVWEB社区】公众号查看最新技术周刊,今天的你比昨天更优秀!


五、利用 extends 拓展类型

extends 拓展了 ES 原生的 extends,在 ts 的语境下,A extends B 意思是既要 A 继承自 B,其作用类似于 & :

interface AdminUser extends User { permission: 100 };
interface User { permission: number, name: string };

function systemReboot(user: AdminUser) {
    if (user.permission < 100) {
        throw new Error('systemReboot error: permission deny');
    } else {
        /** $ sudo reboot ... **/
    }
}

systemReboot({
    permission: 1, // 这里不满足 Admin 的约束 报错哦
    name: '普通用户'
});

systemReboot({
    permission: 100, // 可以 ~
    name: '管理员用户'
});
复制代码

此外,extends 还可以用来约束泛型的范围:

interface HasName {
    name: string;
}

// 这里的意思是 T 作为泛型的话首先要满足 HasName
function sayObjName<T extends HasName>(obj: T) {
    console.log(obj.name); // 不会报错
}

sayObjName({ name: 'eczn' });

sayObjName({});
// 类型不和报错,
// 因为在这里 T 的类型是 {}
// 它并不满足 HasName 的约束
复制代码

六、构造对象索引

在实际代码运行的过程中,我们总是有这样的一种需求

有这样的一种对象 Map:其键是某个唯一 Key,它对应的值是这个 Key 代表的对象

也就是说需要定义「对象的键和值」

在这种情况下,我们可以为这种「对象」声明它的「索引类型」以达到我们的要求:

interface User {
    uid: string;
    name: string;
}

interface ObjMap {
    // 这意思是对象键名类型为 string 其对应的值类型为 User
    [uid: string]: User;
    // string => User
}

const map: ObjMap = {
    'eczn': { uid: 'eczn', name: '喵呜' },
    'eeee': 'ggg' // 不满足,报错
}
复制代码

💡💡💡 如果你喜欢用 Array.prototype.reduce 规约数组的话,对象索引会用的比较多

七、利用 keyof 构造键名联合

keyof 是 ts 提供的类型运算符,用于取出对象类型的键名联合,返回的结果是一个联合类型:

interface Person {
    name: string;
    age: number;
    sex: 0 | 1; // 0 代表女士;1 代表男士
}

type KeyOfPerson = keyof Person;
// 'name' | 'age' | 'sex'
// 这里 KeyOfPerson 的意思是可能是 'name' 可能是 'age' 可能是 'sex'

const personKey: KeyOfPerson = 'xxx';
//                             ^^^^^ 报错 xxx 并不是 Person 键
复制代码

利用 keyof,可以很容易的遍历一个对象的字段,并在原对象的基础上生成新的对象:

// 下面的这个类型会把 T 上面的字段对应的值全部设置为 number
type ObjToNum<T> = {
    [key in keyof T]: number;
}

type Person = {
    name: string;
    address: string;
}

type Test = ObjToNum<Person>;
// Test = { name: number, address: number }
复制代码

ObjToNum 中 key in keyof T 的意思是说, 遍历 keyof T 里的元素作为 key, 将这些 key 作为键,并将这些键所对应的值类型设置为 nunber。

考虑到 key in keyof T,中的 keyof T 可以是任意的联合类型或字面量,因此可以很容易的写出类似下面这样的类型 JustNameAge:

// HasNameAge 用于约束泛型
interface HasNameAge {
    name: any,
    age: any
}

// 将 T 里面的 name 和 age 单独挖出来作为新类型
// (这个新类型是 T 的子集)
type JustNameAge<T extends HasNameAge> = {
    // key 是变量 T[key] 也就很容易理解了
    [key in 'name' | 'age']: T[key]

    // 当然, 最好这样写 (减少冗余)
    // [key in keyof HasNameAge]: T[key]
}

// Test1 => { name: string, age: number }
type Test1 = JustNameAge<{
    name: string,
    age: number,
    sayName: () => void
}>;

// 下面这个会报错
// 因为其泛型入参不满足 HasNameAge 约束
type Test2 = JustNameAge<{
    name: string
}>;
复制代码

八、构造条件类型 Conditional Types

有时候,我们需要去除一个对象的函数项 ... 这里可能需要一般的编程语言里面的 if 判断来进行类型抽象。

首先,我先声明一些基础类型:

// 我们的问题是:
// 如何将 ABC 中的函数项去除,使其变成 type ABC2 = { a: 1 } ?
type ABC = { a: 1, b(): string, c(): number };

// 如果一个值满足这个约束,则这个值为一个函数
type AnyFunc = (...args: any[]) => any;
// (也许你也猜到了,我用它来做形如 T extends AnyFunc 的操作)
复制代码

下一步,我们利用 ? 并结合 extends 做处理:

// 构造 Test1
// Test1 = { a: "a"; b: never; c: never; }
type Test1 = {
    // 这里的意思是 ABC[K] 如果满足 AnyFunc 则取出 K,不然取的是 never
    [K in keyof ABC]: (
        ABC[K] extends AnyFunc ? never : K
        //                              ^^^ 注意这里拿的是 K
    )
}

// 然后构造 Test2
// Test1[keyof ABC]
//   = Test1['a' | 'b' | 'c']
//   = 'a' | never | never  
//   = 'a'
//     ^^^ 注意这里,对于任意类型 A,never | A 最后等于 A
type Test2 = Test1[keyof ABC];


// 然后我们在 Test2 的基础上进行最后一步处理得到 Test3:
// Test3 = { a: 1 }
type Test3 = {
    [K in Test2]: ABC[K]
}
复制代码

把上面的推导过程整理一下,可以得到 GetStaticFor 用于抽取某对象类型的非函数项:

// 这里的思想是取出静态项所对应的 keys
type GetStaticKeysFor<T> = {
    [K in keyof T]: T[K] extends AnyFunc ? never : K
}[keyof T];

// 然后再利用这个 keys 去遍历原对象来取出对应的键值
type GetStaticFor<T> = {
    [K in GetStaticKeysFor<T>]: T[K]
}
复制代码

九、使用 infer 进行 extends 推断

有时候,我们需要将泛型「挖出来」,比如我们需要获取到 Promise 类型里蕴含的值,可以利用 infer 这样使用:

type PromiseVal<P> = P extends Promise<infer INNER> ? INNER : P;

type PStr = Promise<string>;

// Test === string
type Test = PromiseVal<PStr>;
复制代码

此外,infer 只能跟在 extends 的后面出现,因为只有 extends 的语境下,才能体现 infer 的语义:动态地给类型的某个结构命名 以便在后续的 TRUE 分支里面使用。

Array 也可以:

type ArrayVal<P> = P extends Array<infer INNER> ? INNER : P;

// Test ==> string | number
type Test = ArrayVal<[string, number]>;
复制代码

十、本篇末

本篇主要讲述的是如何构造类型抽象以便描述/生成更多的类型,以下是 Checklist:

  1. 掌握本篇当中描述的各种类型抽象方法
  2. 能熟练使用范型、熟练的查看其他人写的类型定义
  3. 通过搭配不同简单抽象来构造更复杂的抽象
  4. 利用类型抽象减少业务代码中类型标注的冗余性,减少重复工作

本文的下一篇是「工程化和运行时、TypeScript 编程内参(三)」,敬请期待

相关链接: 约束即类型、TypeScript 编程内参(一)

关注下面的标签,发现更多相似文章
评论