类型的流动。重新思考TypeScript的类型系统

300 阅读11分钟

简介

TypeScript 提供了一个非常丰富的工具箱。它包括映射类型、基于控制流分析的条件类型、类型推理等等。

对于许多刚接触TypeScript的JavaScript开发者来说,从松散类型切换到静态类型并不是一件容易的事情。即使是那些已经在TypeScript工作多年的开发者,也会因为类型系统的不断发展而感到困惑。

关于高级类型的一个常见的神话是,它应该主要用于构建类型库,在日常的TypeScript工作中并不需要。

事实是,TypeScript高级类型对于日常TypeScript工作非常有用。它们是一个很好的工具,可以在你的代码中构建一个强类型系统,清楚地表达你的意图,并使你的代码更安全。

介绍类型流概念的目的是以一种类似于我们思考反应式编程数据流的方式来思考类型系统。

通过从新的角度看待类型系统,它将帮助我们 "用类型思考",并以系统的方式利用TypeScript工具箱中更高级的工具。

类型可以流动

在反应式编程中,数据在反应式组件之间流动。在TypeScript的类型系统中,类型也可以流动。

我第一次接触到 "类型流动 "的概念是在PranshuKhandal的TypeScript书中。他以如下方式解释了这个想法。

类型的流动只是我在大脑中对类型信息流动的想象。

受此启发,我想将类型流的概念扩展到类型系统层面。我对类型流动的定义是。

类型流动是指一个或多个子类型从一个源类型被映射和转换。这些类型通过类型操作形成一个强约束的类型系统。

类型流的基本形式可以通过类型别名来完成。

类型别名允许你为一个现有的类型创建一个新的类型名称。在下面的例子中,类型别名TargetType 被分配为对SourceType 的引用,因此该类型被转移。

type SourceType = { id: string, quantity: number };
type TargetType= SourceType; // { id: string, quantity: number };

由于类型推理的力量,类型可以以几种不同的方式流动。这些方式包括。

  • 通过数据赋值
  • 通过return type 函数,该函数由return 语句推断;例如,以下函数被推断为返回一个number 类型
  • 与函数参数的模式匹配:如下面的例子所示,用"DecreaseType"类型注释decrease 函数,将a,b 的值转换为number 的类型

下面的代码片断说明了上述情况。

const quantity: number = 4;
const stockQuantity = quantity;
type StockType= typeof stockQuantity; // number

// function return type
function increase(a: number) {
    return a + 1;
}
const result = increase(2); // number

// function parameters matching
type DecreaseType = (start: number, offset: number) => number;
const decrease: DecreaseType = (a,b) => { // a: number, b: number
    return a- b;
}

反应式编程的数据流与类型流的关系

反应式编程的核心是源和反应式组件之间的数据流。它的一些概念与TypeScript的类型系统非常相似,你可以在下图中看到。

Comparing RxJS Operators with TypeScript Types

你可以看到RxJS操作者概念和TypeScript类型化之间的比较。在类型化系统中,类型可以被转换、过滤,并映射到一个或多个子类型。

这些子类型也是 "反应性 "的。当源类型发生变化时,子类型将被自动更新。

一个设计良好的类型系统将为应用程序中的数据和函数添加强类型约束,因此对源类型定义的任何破坏性改变都会立即显示一个编译时错误。

虽然RxJS和TypeScript的类型化有一些相似之处,但两者之间仍有很多区别。例如,RxJS的数据流发生在运行时,而TypeScript的类型流发生在编译时。

在这里引用RxJS的目的是为了说明RxJS中的流概念,希望能帮助我们建立对 "用类型思考 "的共同理解。

类型流中的类型操作

地图和过滤器

在反应式编程中最常使用的两个操作是mapfilter 。我们应该如何在TypeScript中对类型进行这两种操作呢?

射类型相当于RxJS中的映射操作符。它允许我们使用第一个类型的索引签名和通用类型来创建一个基于另一个类型的类型。

当你把条件类型和类型推理结合起来时,你可以用映射类型实现的类型转换是超出想象的。我们将在文章的后面讨论如何使用映射类型。

类型流中的数组的等价物是union类型。为了在union类型上应用过滤器,我们需要使用条件类型和never 类型。由于filter 是一种常见的需求,TypeScript直接提供了excludeextract 实用类型。

下面的代码使用条件类型从T 中移除不能分配给U 的类型。never 类型在这里被用于类型的缩小,或者过滤掉一个联合类型的选项。

type Exclude<T, U> = T extends U ? never : T;
type T1 = Exclude<"a" | "b" | "c" , "a" >; // "b" | "c"
type Extract<T, U> = T extends U ? T: never;
type T2 = Extract<"a" | "b" | "c" , "a" >; // "a"

我们还可以使用开箱即用的TypeScript实用类型来过滤类型属性。

type Omit<T, K extends string | number | symbol> = {[P in Exclude<keyof T, K>]: T[P]; }
type T1 = Omit<{ a: string, b: string }, "a"> //  { b: string; }

用控制流分析来管理流程

使用带有类型保护的控制流分析,我们可以像RxJS中的管道操作者那样对流量进行管理。

下面是一个使用类型保护来执行类型检查的例子,它将类型缩小到一个更具体的类型,并控制逻辑流。

function doSomething(x: A | B) {
 if (x instanceof A) {
   // x is A
 } else {
   // x is B
 }
}

在上面的例子中,TypeScript编译器分析了表达式的所有可能的控制流。它查看了x instance of A ,以确定x 的类型为if 块中的A ,并在else 块中把类型缩小为B

如果我们把这看作是逻辑分支,它可以以连接的方式使用,类似于水在管道中流动的方式,可以被重新引导到不同的、连接的管道中去到达目的地。

使用类型流构建强约束

理论讲完了,让我们把这个想法付诸实践。下面,我们将看看如何通过映射、过滤和转换类型来实现一个良好的约束类型系统,从而将类型流的概念付诸实践。

定义映射器方法

我们正在用映射器模式构建一个Node.js应用程序。为了实现该模式,我们必须首先定义一些映射器方法,这些方法接收数据实体对象并将其映射到数据传输对象(DTO)。

反过来说,还有一组方法将DTO转换为相应的实体对象。

export class myMapper {
   toClient(args: ClientEntity) : ClientDto { ...};
   fromClient(args: ClientDto) : ClientEntity{ ...};
   toOrder(args: OrderEntity) : OrderDto { ...};
   fromOrder(args: OrderDto) : OrderEntity{ ...};
}

我们将使用一个简化的、假想的例子来演示我们的强类型系统的以下两个目标。

  1. 为映射器创建一个具有数据模式中所有方法和接口的类型
  2. 创建一个联合类型来表示实体名称以保证类型安全

定义数据实体和DTO类型

首先,我们需要定义实体和DTO的类型。

type DataSchema = {
  client: {
      dto:  { id: string, name: string},
      entity: {clientId: string, clientName: string}
  },
  order:  {
      dto:  { id: string, amount: number},
      entity: {orderId: string, quantity: number}
  },
}

现在我们已经定义了原始数据类型,那么我们如何从中提取每个实体和DTO类型呢?

我们将使用条件类型和never 来过滤出所需的数据类型定义。

type PropertyType<T, Path extends string> = Path extends keyof T ? T[Path] : never;

type lookup<T, Key extends keyof T, prop extends string> = PropertyType<T[Key], prop>;

我们可以通过将它们合并到一个单一的lookup 类型来简化上述内容。

type lookup<T, Key extends keyof T, prop extends string> = prop
extends keyof T[Key] ? T[Key][prop] : never;

对嵌套属性的支持

上面的lookup 类型只适用于单层属性。当源类型有更多的深度时会发生什么?

为了访问一个具有更多深度的属性类型,我们将创建一个具有递归类型别名的新类型。

type PropertyType<T, Path extends string> =
    Path extends keyof T ? T[Path] :
        Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropertyType<T[K], R> : 
        never :
    never;

type lookup<T, Key, prop extends string> = Key extends keyof T? PropertyType<T[Key], prop>: never;

Path extends keyof T 是Truthy时,这意味着全路径被匹配。因此,我们返回当前的属性类型。

Path extends keyof T 是假的,我们使用infer 关键字来建立一个模式来匹配Path 。如果它匹配,我们会对下一级属性进行递归调用。否则,它将返回一个never ,这意味着Path 与该类型不匹配。

如果它不匹配,就以当前属性为第一参数继续递归。

定义映射器的类型和方法

现在,是时候创建映射器方法了。在这里,我们使用字符串字面类型,在Capitalize实用类型的帮助下形成MapToMapFrom

// MapTo and MapFrom
type MapTo<T extends string> = `to${Capitalize<T>}`;
type MapFrom<T extends string> = `from${Capitalize<T>}`;

把它放在一起

当我们把前面的部分组合在一起时,我们的第一个目标就实现了

我们利用了键重映射功能(即下面代码块中的as 子句),这是从TypeScript 4.1发布后才有的。

也请注意,Key extends string ? Key : never ,因为对象键的类型可以在字符串、数字和符号之间变化。我们在这里只对字符串的情况感兴趣。

type ExtractMapperTo<T> = {
  [Key in keyof T as MapTo<Key extends string ? Key : never>]: (args: lookup<T, Key, 'dto'>) => lookup<T, Key, 'entity'>;
};

type ExtractMapperFrom<T> = {
  [Key in keyof T as MapFrom<Key extends string ? Key : never>]:(args: lookup<T, Key, 'entity'>) => lookup<T, Key, 'dto'>;
};

// Then all these mapper methods are automatically created 
type mapper = ExtractMapperTo<DataSchema> & ExtractMapperFrom<DataSchema>;

我们可以在下面看到,所有的映射器方法接口都是自动创建的。

// Our first goal achieved!
declare const m: mapper;
m.toClient({id: '123', name: 'John'});
m.fromClient({clientId: '123', clientName: 'John'});
m.toOrder({id: '123', amount: 3});
m.fromOrder({orderId: '345',quantity: 4});

我们也有很好的IDE IntelliSense支持。

IDE IntelliSense support

将一个对象类型转换为一个联合类型

我们的下一个目标是创建一个联合类型来表示来自源DataSchema 类型的数据类型名称。

该解决方案的关键是PropToUnion<T> 类型。

// Derive the data type names into a union type
type PropToUnion<T> = {[k in keyof T]: k}[keyof T];

type DataTypes = PropToUnion<DataSchema>; // 'client' | 'order'

首先,{[k in keyof T]: k} ,使用keyof ,将T 的键提取为键和值。输出结果是。

{
   client: "client";
   order: "order";
}

然后,我们使用索引签名[keyof T] ,将值提取为一个联合类型。

生成的联合类型可以帮助我们执行类型安全。假设我们把下面这个函数放在了远离源类型的另一个模块中。在getProcessName 函数中,switch 语句触发了类型保护,never 在默认情况下被返回,以告诉编译器它不应该被到达。

// Second goal achieved
function getProcessName(c: DataTypes): string {
    switch(c) {
        case 'client':
            return 'register' + c;
        case 'order':
            return 'process' + c;
        default:
        return assertUnreachable(c);
    }   
}
function assertUnreachable(x: never): never {
    throw new Error("something is very wrong");
}

这就是联合类型和never 如何帮助执行类型安全。

现在,让我们假设数据模式发生了变化--我们添加了一个新的数据类型,叫做account 。在一个大的团队中,添加新类型的开发人员可能不知道这个变化的影响。如果没有类型约束,它可能会导致一个难以发现的隐藏的运行时错误。

如果我们使用类型流来构建类型化约束,下游的子类型将被自动更新,如下图。

type DataTypes = "client" | "order" | "Account"

TypeScript编译器也会在getProcessName 函数中显示一个错误,以提示我们发生了破坏性变化。

A compiler error due to a breaking change

我们的第二个目标已经实现了!我们有了一个联合类型,它现在代表实体名称,并有助于类型安全。

回顾一下,这个图显示了我们为实现类型流动的第一个目标所采取的主要步骤。

A visual recap demonstrating the flow of types

总的来说,我们在原始源类型的基础上创建了几个新类型。对源类型的任何改变都会自动触发对所有下游类型的更新,如果这个改变破坏了依赖它的函数,我们会立即得到错误提示。

完整的示例代码可以在下面的Gist中找到。

摘要

本文讨论了TypeScript类型流的概念,并参考了RxJS中的反应式编程。我们将类型流的概念应用于一个实际的例子,通过建立一个良好的约束类型系统来最大化类型安全的好处。

我希望这个讨论有助于改变TypeScript的高级类型只用于开发类型库或复杂的框架级编程的想法。我也希望它能帮助你在日常的TypeScript工作中开始更有创造性地应用类型系统。

祝你打字愉快!

The postType flowing:TypeScript类型系统的反思首次出现在LogRocket博客上。