TypeScript 类型元编程基础入门

更新日期: 2021-06-26阅读: 1.2k标签: TypeScript

现在,TypeScript 已经在前端圈获得了广泛的群众基础。但据个人观察,很多同学还处于刚刚脱离 AnyScript 的阶段,看到 K in keyof T 这类东西就头疼,读不懂现代前端框架中普遍使用的类型操作技巧。如果你也对类型体操感到一头雾水,本文或许能为你提供一些授人以渔式的帮助。

由于本文预期的受众是完全没有高级类型操作经验的同学,因此下面我们不会直接列出一堆复杂的类型体操案例,而是从最简单的泛型变量语法等基础知识开始,逐步展示该如何从零到一地使用 TS 中强大的 type-level 编程能力。这些内容可以依次分成三个部分:

循环依赖与类型空间
类型空间与类型变量
类型变量与类型体操

在开始介绍具体的类型操作语法前,这里希望先铺垫个例子,借此先理清楚「 TypeScript 相比于 JavaScript 到底扩展出了什么东西 」,这对后面建立思维模型会很有帮助。


循环依赖与类型空间

我们都知道,JavaScript 中是不建议存在循环依赖的。假如我们为一个编辑器拆分出了 Editor 和 Element 两个 class,并把它们分别放在 editor.js 和 element.js 里,那么这两个模块不应该互相 import 对方。也就是说,下面这种形式是不提倡的:

// editor.js
import { Element } from './element'

// element.js
import { Editor } from './editor'

但是在 TypeScript 中,我们很可能必须使用这样的「循环依赖」。因为往往不仅在 Editor 实例里要装着 Element 的实例,每个 Element 实例里也需要有指回 Editor 的引用。由于类型标注的存在,我们就必须这么写:

// editor.ts
import { Element } from './element'

// Editor 中需要建立 Element 实例
class Editor {
  constructor() {
    this.element = new Element();
  }
}

// element.ts
import { Editor } from './editor'

// Element 中需要标注类型为 Editor 的属性
class Element {
  editor: Editor
}

这不就造成了 JS 中忌讳的循环引用了吗?当然这么写倒也不是不能用,因为这里为了类型标注而写的 import 不会出现在编译出的 JS 代码中(说粗俗点就是「编译以后就没了」,后面会详细解释)。但比较熟悉 TS 的同学应该都知道,这时的最佳实践是使用TypeScript 3.8 中新增的 import type 语法:

// element.ts
import type { Editor } from './editor'

// 这个 type 可以放心地用作类型标注,不造成循环引用
class Element {
  editor: Element
}

// 但这里就不能这么写了,会报错
const editor = new Editor()

现在问题来了, import type 这个语法和普通的 import 有什么效果上的区别呢?基于这个语法导入的 Editor 到底又是什么呢?为什么这时的 Editor 不能拿来 new 呢?这时我们就需要知道类型空间的概念了。

不同于使用动态类型的 JavaScript,像 TypeScript 这样的现代静态类型语言,一般都具备两个放置语言实体的「空间」,即类型空间( type-level space )和值空间( value-level space )。前者用于存放代码中的类型信息,在运行时会被完全擦除掉。而后者则存放了代码中的「值」,会保留到运行时。

那么这里的「值」是什么呢?字面量、变量、常量、函数形参、函数对象、class、enum……它们都是值,因为这些实体在编译出的 JS 中都会保留下来。而相应地,类型空间中则存放着所有用 type 关键字定义的类型,以及 interface、class 和 enum—— 也就是所有能拿来当作类型标注的东西 。

注意到重复的地方了吗?没错,class 和 enum 是横跨两个空间的!这其实很好理解,比如对于一个名为 Foo 的 class,我们在写出 let foo: Foo 的时候,使用的是类型空间里的 Foo 。而在写出 let foo = new Foo() 时,使用的则是值空间里的 Foo 。因为前者会被擦除掉,后者会保留在 JS 里。

只要明白这一点,上面的问题就全都迎刃而解了:这里的 import type 相当于只允许所导入的实体在类型空间使用,因此上面导入的 Editor 就被限定在了类型空间,从而杜绝了值空间(JS)中潜在的循环引用问题。

通俗地说,值空间在第一层,类型空间在第二层,Anders 老爷子在大气层。

现在,我们已经通过对 import type 语法的观察,明白了 TypeScript 中实际上存在着两个不同的空间。那么在这个神秘的类型空间里,我们能做什么呢?有句老话说得好,广阔天地,大有可为。


类型空间与类型变量

显然,类型空间里容纳着的是各种各样的类型。而非常有趣的是,编程语言中的「常量」和「变量」概念在这里同样适用:

当我们写 let x: number 时,这个固定的 number 类型就是典型的常量。如果我们把某份 JSON 数据的字段结构写成朴素的 interface,那么这个 interface 也是类型空间里的常量。

在使用泛型时,我们会遇到类型空间里的变量。这里的「变」体现在哪里呢?举例来说, 通过泛型,函数的返回值类型可以由输入参数的类型决定 。如果纯粹依靠「写死」的常量来做类型标注,是做不到这一点的。

在 TypeScript 中使用泛型的默认方式,相比 Java 和 C++ 这些(名字拼写)大家都很熟悉的经典语言,并没有什么区别。这里重要的地方并不是 <T> 形式的语法,而是这时我们 实际上是在类型空间中定义了一个类型变量 :

// 使用泛型标注的加法函数,这里的 Type 就是一个类型变量
function add<Type>(a: Type, b: Type): Type {
  return a + b;
}

add(1, 2) // 返回值类型可被推断为 number
add('a', 'b') // 返回值类型可被推断为 string

add(1, 'b') // 形参类型不匹配,报错

在上面这个非常简单的例子里,通过 Type 这个类型变量,我们不仅在输入参数和返回值的类型之间建立了动态的联系,还在输入参数之间建立了约束,这是一种很强大的表达力。另外由于 这类变量在语义上几乎总是相当于占位符 ,所以我们一般会把它们简写成 T / U / V 之类。

除了声明类型变量以外,另一种能在类型空间里进行的重要操作,就是从一种类型推导出另一种类型。TypeScript 为此扩展出了自己定义的一套语法,比如一个非常典型的例子就是 keyof 运算符。这个运算符是专门在类型空间里使用的,(不太准确地说)相当于类型空间里的 Object.keys ,像这样:

// 定义一个表达坐标的 Point 结构
interface Point {
  x: number
  y: number
}

// 取出 Point 中全部 key 的并集
type PointKey = keyof Point

// a 可以是任意一种 PointKey
let a: PointKey;

a = 'x' // 通过
a = 'y' // 通过
a = { x: 0, y: 0 } // 报错

值得注意的是, Object.keys 返回的是一个数组,但 keyof 返回的则是一个集合。如果前者返回的是 ['x', 'y'] 数组,那么后者返回的就是 'x' | 'y' 集合。我们也可以用形如 type C = A | B 的语法来取并集,这时其实也是在类型空间进行了 A | B 的表达式运算。

除了 keyof 运算符以外,在类型空间编程时必备的还有泛型的 extends 关键字。它在这里的语义并非 class 和 interface 中的「继承」,而更类似于 由一个类型表达式来「衍生」出另一个类型变量 :

// identity 就是返回原类型自身的简单函数
// 这里的 T 就相当于被标注成了 Point 类型
function identity1<T extends Point>(a: T): T {
  return a;
}

// 这里的 T 来自 Point2D | Point3D 这个表达式
function identity2<T extends Point2D | Point3D>(a: T): T {
  return a;
}

现在,我们已经清楚地意识到了类型变量的存在,并且也知道我们能在类型空间里「 基于类型来生成新类型 」了。经过这个热身,你是不是已经按捺不住继续尝试体操动作的热情了呢?不过在继续往下之前,这里先总结一下这么几点吧:

类型空间里同样可以存在变量,其运算结果还可以赋值给新的类型变量。实际上 TypeScript 早已做到让这种运算图灵完备了。
类型空间里的运算始终只能针对类型空间里的实体,无法涉及运行时的值空间。比如从后端返回的 data 数据里到底有哪些字段,显然不可能在编译期的类型空间里用 keyof 获知。不要尝试表演超出生理极限的体操动作。
类型空间在运行时会被彻底擦除,因此你哪怕完全不懂不碰它也能写出业务逻辑,这时就相当于回退到了 JavaScript。

因此,TypeScript 看似简单的类型标注背后,其实是一门隐藏在类型空间里的强大编程语言。虽然目前我们还只涉及到了对其最基础的几种用法,但已经可以组合起来发挥出更大的威力了。下面将从一个实际例子出发,介绍在类型空间进行更进阶操作时的思路。


类型变量与类型体操

怎样的类型操作算是「类型体操」呢?充斥着 T / U / V 等类型变量的代码可能算是一种吧。由于这时我们做的已经是在类型空间里进行的元编程,必须承认这类代码常常是较为晦涩的。但这种能力很可能获得一些意想不到的好处, 甚至能对应用性能有所帮助 ——你说什么?类型在运行时被通通擦除掉的 TypeScript,怎么可能帮助我们提升性能呢?

现在,假设我们在开发一个支持多人实时协作的编辑器。这时一个非常基础的需求,就是要能够将操作(operation)序列化为可传输的数据,在各个用户的设备上分布式地应用这些操作。一般来说,这类数据的结构都是数据模型中某些字段的 diff 结果,我们可以像应用 git patch 那样地把它应用到数据模型上。而由于每次操作所更新的字段都完全随机, 为了保存历史记录,我们需要将更新前后的字段数据都一起存起来 ,像这样:

class History {
  commit(element, from, to) {
    // ...
  }
}

// 更新单个字段
history.commit(element, { left: 0 }, { left: 10 })

// 或者更新多个字段
history.commit(
  element,
  { left: 5, top: 5 },
  { left: 10, top: 10 }
)

基于工程经验,这个 commit 方法需要满足两个目标:

能够适配所有不同类型的 Element。
对每种 Element,调用方所能提交的字段格式要能够被约束住 。比如只允许为 TextElement 提交 text 字段,只允许为 ImageElement 提交 src 字段。

对于这两条要求,在原生的 JavaScript 中我们该怎么做呢?由于 JavaScript 是弱类型语言,第一条要求可以容易地满足。但对于约束字段的第二条要求,则通常需要由运行时的校验逻辑来实现:

class History {
  commit(element, from, to) {
    // 要求所有 `from` 中的 key 都存在于 `to` 中
    const allKeyExists = Object
      .keys(from)
      .every(key => !!to[key])
    // 要求 `from` 中的 key 长度和 `to` 一致
    const keySizeEqual = (
      Object.keys(from).length === Object.keys(to).length
    )
    // 仅当同时满足上面两条时才通过校验
    if (!(allEeyExists && keySizeEqual)) {
      throw new Error('you fxxking idiot!');
    }

    // ...
  }
}

显然,这是一个 O(N) 复杂度的算法,并且还无法涵盖更精细的逻辑。例如这段代码无法检查存在嵌套的字段,只能检查对象 key 的名称而忽略了 value 的类型,不能根据 Element 的类型来校验字段的有效性……诸如此类的校验如果越写越复杂,还有一种工程上的变通方案,那就是通过 NODE_ENV 之类的环境变量,将这类校验代码限制在开发版的 JS 包里,在打运行时包时由编译器优化掉, 像 react 就做了这样的处理 。这些确实倒是也都能做,但是何苦呢?

其实,通过上面介绍的几个 TypeScript 操作符,我们就可以将这类校验直接在编译期完成了。首先让我们来满足第一条的通用性要求:

interface ImageElement {}
interface TextElement {}
// 无需继承,可以直接通过类型运算来实现
type Element = ImageElement | TextElement

class History {
  // 直接标注 Element 类型
  commit(element: Element, from, to) {
    // ...
  }
}

上面的代码没有用到任何黑魔法,这里就不赘述了。重点在于第二条,如何根据 Element 类型来约束字段呢?联想到上面的 keyof 操作符, 我们很容易在类型空间里取出 Element 的所有 key ,并且还可以类比 ES6 中的 { [x]: 123 } 语法,构建出类型空间里的新结构:

type T = keyof Element // T 为 'left' | 'top' 等字段的集合

// 将所有 T 的可选项作为 key
// 以 Element 中相应 value 的类型为 value
// 以此结构建立出一个新的类型变量
type MyElement1 = { [K in T]: Element[K] }

// 等价于这么写
type MyElement2 = { [K in keyof Element]: Element[K] }

let a: MyElement1 // 可以提示出 a.left 等 Element 中的字段

基于上面这些能力,我们就可以开始做体操动作了!首先我们在方法定义里引入泛型变量:

class History {
  commit<T extends Element>(element: T, from, to) {
    // ...
  }
}

然后对这个 T 做 keyof 操作,用它的 key 来约束类型:

class History {
  // U 的结构被限定成了 T 中所存在的 key-value 
  commit<T extends Element, U = { [K in keyof T]: T[K] }>(
    element: T,
    from: U,
    to: U
  ) {
    // ...
  }
}

// 这样我们仍然可以这样调用
history.commit(element, { left: 0 }, { left: 10 })

// 这样的字段 bug 就可以在编译期被发现
history.commit(element, { a: 0 }, { b: 1 })

然而上面的操作还不够,因为它无法解决下面这个问题:

// 虽然 `from` 和 `to` 都有效,但它们二者的字段却对不上
history.commit(element, { left: 0 }, { top: 10 })

某种程度上,这种 bug 才是最可能出现的。我们能进一步通过类型操作来规避它吗?只要再引入一个类型变量,依葫芦画瓢地再做一次 keyof 操作就可以了:

class History {
  commit<
    T extends Element,
    U = { [K in keyof T]: T[K] },
    // 第二个参数的结构来自 T,而第三个参数的结构又来自第二个参数
    V = { [K in keyof U]: U[K] }
  >(element: T, from: U, to: V) {
    // ...
  }
}

// 现在 `from` 和 `to` 的字段就必须完全一致了
history.commit(element, { left: 0 }, { left: 10 })

// 上面问题就可以在编译期被校验掉
history.commit(element, { left: 0 }, { top: 10 })

好了,这个难度系数仅为 0.5 的体操顺利完成了。通过这种方式,我们通过寥寥几行类型空间的代码,就能借助 TypeScript 类型检查器到威力,将原本需要放在运行时的校验逻辑直接优化到在编译期完成,从而在性能和开发体验上都获得明显的提升,直接赢两次!

当然,相信可能很多同学会指出,这种手法还只能对代码中的逻辑进行校验,无法涉及运行时。但其实只要通过运行时库, TypeScript 也可以用来写出语义化的运行时校验 !我贡献过的 Superstruct 和@工业聚 的 Farrow 都做到了这一点(Farrow 已经做成了全家桶式的 Web 框架,但个人认为其中最创新的地方是其中可单独使用的 Schema 部分)。比如这样:

import { assert, object, number, string, array } from 'superstruct'

// 定义出校验结构,相当于运行时的 interface
const Article = object({
  id: number(),
  title: string(),
  tags: array(string()),
  author: object({
    id: number(),
  }),
})

const data = {
  id: 34,
  title: 'Hello World',
  tags: ['news', 'features'],
  author: {
    id: 1,
  },
}

// 这个 assert 发生在运行时而非编译时
assert(data, Article)

这样一来,我们就以 schema 为抓手,将类型空间的能力下沉到了值空间,拉通了端到端类型校验的全链路,形成了强类型闭环,赋能了运行时业务,打出了一套组合拳。试问能够如此这般成就用户的 TypeScript 赛道,足够击穿你的心智吗?


总结

本文对 TypeScript 中隐藏着的类型空间做了介绍,并介绍了在其中进行操作的一些基本手法(声明类型变量、从类型生成新类型等等)。实际上不仅仅是这么一两个函数的层面。对于未来的 low code 系统,如果我们对类型检查器具备更多的掌控,那么就有机会获得一些奇妙的的进步(举个例子,你觉得表单算不算一种依赖类型呢)。这方面仍然有非常大的想象空间。

原文 https://zhuanlan.zhihu.com/p/384172236

链接: https://www.fly63.com/article/detial/10467

用TypeScript弥补Elm和JavaScript之间的差距

近些日子,我使用了新语言编程,从JavaScript,切确地说是Elm,转成TypeScript。在本文中,我将继续深挖一些我非常喜欢的TypeScript特性。

Typescript 和 Javascript之间的区别

TypeScript 和 JavaScript 是目前项目开发中较为流行的两种脚本语言,我们已经熟知 TypeScript 是 JavaScript 的一个超集,但是 TypeScript 与 JavaScript 之间又有什么样的区别呢?

Nerv_一款类 React 前端框架,基于虚拟 DOM 技术的 JavaScript(TypeScript) 库

Nerv_是一款由京东凹凸实验室打造的类 React 前端框架,基于虚拟 DOM 技术的 JavaScript(TypeScript) 库。它基于React标准,提供了与 React 16 一致的使用方式与 API。

TypeScript_TS系列之高级类型

交叉类型:将多个类型合并为一个类型、联合类型:表示取值可以为多种类型中的一种、混合类型:一个例子就是,一个对象可以同时做为函数和对象使用,并带有额外的属性、类型断言:可以用来手动指定一个值的类型

TypeScript 在 JavaScript 的基础上的改动

在做比较大的,多人合作的项目的时候,TypeScript会更加地适合,这得益于它的可读性,面向对象性以及易于重构的特点。但如果只是自己做小程序,不需要太多人参与的时候,JavaScript则会更加简单。

5分钟了解TypeScript

有两种方式安装TypeScript,如何创建第一个TypeScript文件,在TypeScript中,可以使用interface来描述一个对象有firstName和lastName两个属性,TypeScript支持JavaScript的新功能,其中很重要的一个功能就是基于类的面向对象编程

如何编写 Typescript 声明文件

使用TypeScript已经有了一段时间,这的确是一个好东西,虽说在使用的过程中也发现了一些bug,不过都是些小问题,所以整体体验还是很不错的。有关TypeScript声明类型声明相关的目前就总结了这些比较常用的

谷歌为何会选用TypeScript?

谷歌在很早之前就张开双臂拥抱 Web 应用程序,Gmail 已经发布 14 年了。当时,JavaScript 的世界是疯狂的。Gmail 工程师不得不为 IE 糟糕的垃圾回收算法捏一把汗,他们需要手动将字符串文字从 for 循环中提取出来,以避免 GC 停顿

为什么要学习Typescript 语言呢?Typescript 开发环境安装

TypeScript是一种由微软开发的自由和开源的编程语言。它是JavaScript的一个超集,TypeScript是JavaScript类型的超集,它可以编译成纯JavaScript。TypeScript可以在任何浏览器、任何计算机和任何操作系统上运行,并且是开源的。

使用TypeScript两年后-值得吗?

差不多两年前,我在一个创业团队中开始了一个全新的项目。用到的全都是类似Microservices,docker,react,redux这些时髦的东西。我在前端技术方面积累了一些类似的经验

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!