TypeScript 随想 · 实际应用与技巧

目录

  • 类型元编程
  • 内置工具类型窥探
  • 外部工具类型推荐
  • 新操作符
  • 声明文件
类型元编程 什么是元编程:
维基百科是这样描述的:元编程是一种编程技术,编写出来的计算机程序能够将其他程序作为数据来处理。意味着可以编写出这样的程序:它能够读取、生成、分析或者转换其它程序,甚至在运行时修改程序自身。在某些情况下,这使程序员可以最大限度地减少表达解决方案的代码行数,从而减少开发时间。它还允许程序更灵活有效地处理新情况而无需重新编译。
简单的说,元编程能够写出这样的代码:
  • 可以生成代码
  • 可以在运行时修改语言结构,这种现象被称为反射编程(Reflective Metaprogramming)或反射(Reflection)
什么是反射:
反射是元编程的一个分支,反射又有三个子分支:
  1. 自省(Introspection):代码能够自我检查、访问内部属性,我们可以据此获得代码的底层信息。
  2. 自我修改(Self-Modification):顾名思义,代码可以修改自身。
  3. 调解(Intercession):字面意思是「代他人行事」,在元编程中,调解的概念类似于包装(wrapping)、捕获(trapping)、拦截(intercepting)。
举个实际一点的例子
  • ES6(ECMAScript 2015)中用 Reflect(实现自省)和 Proxy(实现调解) 进行编码操作,称之为是一种元编程。
  • ES6 之前利用 eval 生成额外的代码,利用 Object.defineProperty 改变某个对象的语义等。
TypeScript 的类型元编程
个人感觉「元编程」这个概念并没有标准的明确的定义,所以本文这里就把在 TypeScript 中使用 infer、keyof、in 等关键字进行操作,称之为是 TypeScript 的类型元编程。或者说是「偏底层一点的特性」或者「骚操作」,大家明白其用途即可。
unknown
unknown type 是 TypeScript 中的 Top Type。符号是(?), 换句话说,就是任何类型都是 unknown 的子类型,unknown 是所有类型的父类型。换句最简单的话说,就是 任何值都可以赋值给类型是 unkown 的变量,与其对应的是,我们不能把一个 unkown 类型的值赋值给任意非 unkown 类型的值。
let a: unknown = undefined a = Symbol('deep dark fantasy') a = {} a = false a = '114514' a = 1919nlet b : bigint = a; // Type 'unknown' is not assignable to type 'bigint'.

never
never 的行为与 unknown 相反,never 是 TypeScript 中的 Bottom Type,符号是(⊥),换句话说,就是任何类型都是 never 的父类型,never 是所有类型的子类型。
【TypeScript 随想 · 实际应用与技巧】也可以顾名思义,就是「永远不会」=>「不要」的意思,never 与 infer 结合是常见体操姿势,下文会介绍。
let a: never = undefined // Type 'undefined' is not assignable to type 'never'

keyof
可以用于获取对象或数组等类型的所有键,并返回一个联合类型
interface Person { name: string age: number }type K1 = keyof Person// "name" | "age"type K2 = keyof []// "length" | "toString" | "push" | "concat" | "join"type K3 = keyof { [x: string]: Person }// string | number

in
在映射类型中,可以对联合类型进行遍历
type Keys = 'firstName' | 'lastName'type Person = { [key in Keys]: string }// Person: { firstName: string; lastName: string; }

[]
索引操作符,使用 [] 操作符可以进行索引访问,所谓索引,就是根据一定的指向返回相应的值,比如数组的索引就是下标 0, 1, 2 等。TypeScript 里的索引签名有两种:字符串索引和数字索引。
字符串索引(对象) 对于纯对象类型,使用字符串索引,语法:T[key]
interface Person { name: string age: number }type Name = Person['name']// Name: string

索引类型本身也是一种类型,因此还可以使用联合类型或者其他类型进行操作
type I1 = Person['name' | 'age']// I1: string | numbertype I2 = Person[keyof Person]// I2: string | number

数字索引(数组) 对于类数组类型,使用数字索引,语法:T[number]
type MyArray = ['Alice', 'Bob', 'Eve']type Alice = MyArray[0]// 'Alice'type Names = MyArray[number]// 'Alice' | 'Bob' | 'Eve'

实际一点的例子
const PLAYS = [ { value: 'DEFAULT', name: '支付送', desc: '用户支付后即获赠一张券', }, { value: 'DELIVERY_FULL_AMOUNT', name: '满额送', desc: '用户支付满一定金额可获赠一张券', checkPermission: true, permissionName: 'fullAmount', }, ]type Play = typeof PLAYS[number]/* type Play = { value: string; name: string; desc: string; checkPermission?: undefined; permissionName?: undefined; } | { value: string; name: string; desc: string; checkPermission: boolean; permissionName: string; } */

泛型(generic)
软件工程中,我们不仅要创建一致的定义良好的 API,同时也要考虑可重用性。组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时非常有用。
实际例子,封装 ajax 请求库,支持不同的接口返回它该有的数据结构。
function ajax(options: AjaxOptions): Promise { // actual logic... }function queryAgencyRole() { return ajax<{ isAgencyRole: boolean }>({ method: 'GET', url: '/activity/isAgencyRole.json', }) }function queryActivityDetail() { return ajax<{ brandName: string; }>({ method: 'GET', url: '/activity/activityDetail.json', }) }const r1 = await queryAgencyRole()r1.isAgencyRole// r1 里可以拿到 isAgencyRoleconst r2 = await queryActivityDetail()r2.brandName// r2 里可以拿到 brandName

extends
在官方的定义中称为条件类型(Conditional Types),可以理解为「三目运算」,T extends U ? X : Y,如果 T 是 U 的子集,那么就返回 X 否则就返回 Y。
  • 一般与泛型配合使用。
  • extends 会遍历联合类型,返回的也是联合类型。
type OnlyNumber = T extends number ? T : nevertype N = OnlyNumber<1 | 2 | true | 'a' | 'b'>// 1 | 2

通常情况下,分布的联合类型是我们想要的, 但是也可以让 extends 不遍历联合类型,而作为一个整体进行判断与返回。只需要在 extends 关键字的左右两侧加上方括号 [] 进行修饰即可。
// 分布的条件类型 type ToArray = T extends any ? T[] : never; type R = ToArray; // type R = string[] | number[]

// 不分布的条件类型 type ToArrayNonDist = [T] extends [any] ? T[] : never; type R = ToArrayNonDist; // type R = (string | number)[]

infer
infer 关键字可以对运算过程中的类型进行存储,类似于定义一个变量。
内置的工具类型 ReturnType 就是基于此特性实现的。
type ReturnType = T extends (...args: any) => infer R ? R : any; type R1 = ReturnType<() => number>// R1: number type R2 = ReturnType<() => boolean[]>// R2: boolean[]

递归(recursion)
在 TypeScript 中递归也是调用(或引用)自己,不过不一定需要跳出。
如下,定义 JSON 对象的标准类型结构。
// 定义基础类型集 type Primitive = string | number | boolean | null | undefined | bigint | symbol// 定义 JSON 值 type JSONValue = https://www.it610.com/article/Primitive | JSONObject | JSONArray// 定义以纯对象开始的 JSON 类型 interface JSONObject { [key: string]: JSONValue }// 定义以数组开始的 JSON 类型 type JSONArray = Array

提个小问题:为什么 TypeScript 不跳出递归也不会陷入死循环?
But apart from being computationally intensive, these types can hit an internal recursion depth limit on sufficiently-complex inputs. When that recursion limit is hit, that results in a compile-time error. In general, it’s better not to use these types at all than to write something that fails on more realistic examples.
--from https://www.typescriptlang.or...
typeof
概念:像 TypeScript 这样的现代静态类型语言,一般具备两个放置语言实体的「空间」,即类型空间(type-level space)和值空间(value-level space),前者用于存放代码中的类型信息,在运行时会被完全擦除掉;后者用于存放代码中的「值」,会保留到运行时。
  • 值空间:变量、对象、数组、class、enum 等。
  • 类型空间:type、interface、class、enum 等。
typeof 的作用是把「值空间」的数据转换成「类型空间」的数据。
const MARKETING_TYPE = { ISV: 'ISV_FOR_MERCHANT', ISV_SELF: 'ISV_SELF', MERCHANT: 'MERCHANT_SELF', }type MarketingType = typeof MARKETING_TYPE/* type MarketingType = { ISV: string; ISV_SELF: string; MERCHANT: string; } */

as const
as const 是一个类型断言,作用也是把「值空间」的数据转换成「类型空间」的数据,并且设置成只读。
let x = 'hello' as const; // x: 'hello'let y = [10, 20] as const; // y: readonly [10, 20]let z = { text: 'hello' } as const; // z: { readonly text: 'hello' }

实际一点的例子:
const MARKETING_TYPE = { ISV: 'ISV_FOR_MERCHANT', ISV_SELF: 'ISV_SELF', MERCHANT: 'MERCHANT_SELF', } as consttype MT = typeof MARKETING_TYPEtype MarketingType = MT[keyof MT]/* type MT = { readonly ISV: "ISV_FOR_MERCHANT"; readonly ISV_SELF: "ISV_SELF"; readonly MERCHANT: "MERCHANT_SELF"; }type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF" */

内置工具类型窥探 TypeScript 内置了一些实用的工具类型,可以提高开发过程中类型转换的效率。
基于上面的了解,再来阅读内置工具类型就很轻松了,这里我们就列举几个常用或者有代表性的工具类型。
Partial
作用:把对象的每个属性都变成可选属性。
interface Todo { title: string; description: string; }type NewTodo = Partial/* type NewTodo = { title?: string; description?: string; } */

原理:把每个属性添加 ? 符号,使其变成可选属性。
type Partial = { [P in keyof T]?: T[P]; };

Required
作用:与 Partial 相反,把对象的每个属性都变成必填属性。
interface Todo { title?: string; description?: string; }type NewTodo = Required/* type NewTodo = { title: string; description: string; } */

原理:给每个属性添加 -? 符号,- 指的是去除,-? 意思就是去除可选,就变成了 required 类型。
type Required = { [P in keyof T]-?: T[P]; };

Readonly
作用:把对象的每个属性都变成只读属性。
interface Todo { title: string; description: string; }type NewTodo = Readonly/* type NewTodo = { readonly title: string; readonly description: string; } */const todo: Readonly = { title: 'Delete inactive users' }// Cannot assign to 'title' because it is a read-only property. todo.title = "Hello";

原理:给每个属性添加 readonly 关键字,就变成了只读属性。
type Readonly = { readonly [P in keyof T]: T[P]; };

Pick
作用:与 lodash 的 pick 方法一样,挑选对象里需要的键值返回新的对象,不过这里挑选的是类型。
interface Todo { title: string; description: string; completed: boolean; } type TodoPreview = Pick/* type TodoPreview = { title: string; completed: boolean; } */

原理:使用条件类型约束传入的联合类型 K,然后再对符合条件的联合类型 K 进行遍历。
type Pick = { [P in K]: T[P]; };

Omit
作用:与 Pick 工具方法相反,排除对象的某些键值。
interface Todo { title: string; description: string; completed: boolean; } type TodoPreview = Omit/* type TodoPreview = { title: string; completed: boolean; } */

原理:与 Pick 类似,不过是先通过 Exclude 得到排除后的剩余属性,再遍历生成新对象类型。
type Omit = Pick>;

Exclude
作用:排除联合类型里的一些成员类型。
type T0 = Exclude<'a' | 'b' | 'c', 'a'>// T0: 'b' | 'c'type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>// T1: 'c'

原理:通过条件类型 extends 把不需要的类型排除掉。
type Exclude = T extends U ? never : T;

Parameters
作用:获取函数的参数类型,返回的是一个元组类型
type T0 = Parameters<() => string>// T0: [] type T1 = Parameters<(s: string) => void>// T1: [s: string]

原理:通过 infer 关键字获取函数的参数类型并返回
type Parameters any> = T extends (...args: infer P) => any ? P : never;

ReturnType
作用:获取函数的返回类型
type R1 = ReturnType<() => number>// R1: number type R2 = ReturnType<() => boolean[]>// R2: boolean[]

原理:通过 infer 关键字获取函数返回类型
type ReturnType any> = T extends (...args: any) => infer R ? R : any;

Awaited
作用:取得无 Promise 包裹的原始类型。
type res = Promise<{ brandName: string }>type R = Awaited// R: { brandName: string }

原理:如果是普通类型就返回该类型,如果是 Promise 类型,就用 infer 定义 then 的值,并返回。
type Awaited = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F): any } // 检查 Promise 类型 ? F extends (value: infer V, ...args: any) => any ? Awaited// 递归 value 类型 : never// 不符合规则的 Promise 类型丢弃 : T; // 不是 Promise 类型直接返回

Promise 类型形状如下
/** * Represents the completion of an asynchronous operation */ interface Promise { /** * Attaches callbacks for the resolution and/or rejection of the Promise. * @param onfulfilled The callback to execute when the Promise is resolved. * @param onrejected The callback to execute when the Promise is rejected. * @returns A Promise for the completion of which ever callback is executed. */ then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; /** * Attaches a callback for only the rejection of the Promise. * @param onrejected The callback to execute when the Promise is rejected. * @returns A Promise for the completion of the callback. */ catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise; }

获取 Promise 类型的另一种简单实现:
type Awaited = T extends PromiseLike ? Awaited : T

外部工具类型推荐 市面上有 2 款 star 比较多的开源工具库
type-fest: https://github.com/sindresorh...
utility-types: https://github.com/piotrwitek...
type-fest 没有用过,介绍一下 utility-types 的 ValuesType,比较常用。
ValuesType
获取对象或数组的值类型。
interface Person { name: string age: number }const array = [0, 8, 3] as consttype R1 = ValuesType// string | number type R2 = ValuesType// 0 | 8 | 3 type R3 = ValuesType// 8 | 7 | 6

实际例子:获取 JS 常量的值类型,避免重复劳动。
const MARKETING_TYPE = { ISV: 'ISV_FOR_MERCHANT', ISV_SELF: 'ISV_SELF', MERCHANT: 'MERCHANT_SELF', } as consttype MarketingType = ValuesType// type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF"

实现原理:使用上文说到的「字符串索引」和「数字索引」来取值。
type ValuesType< T extends ReadonlyArray | ArrayLike | Record > = T extends ReadonlyArray ? T[number] : T extends ArrayLike ? T[number] : T extends object ? T[keyof T] : never;

新操作符 [2.0] Non-null assertion operator(非空断言符)
断言某个值存在
function createGoods(value: number): { type: string } | undefined { if (value < 5) { return } return { type: 'apple' } }const goods = createGoods(10)goods.type// ERROR: Object is possibly 'undefined'. (2532)goods!.type// ?

[3.7] Optional Chaining(可选链操作符)
可选链操作符可以跳过值为 null 和 undefined 的情况,只在值存在的情况下才会执行后面的表达式。
let x = foo?.bar()

编译后的结果如下:
let x = foo === null || foo === void 0 ? void 0 : foo.bar();

实际场景的对比:
// before if (user && user.address) { // ... }// after if (user?.address) { // ... }// 语法: obj.val?.prop// 属性访问 obj.val?.[expr]// 属性访问 obj.arr?.[index]// 数组访问 obj.func?.(args)// 函数调用

[3.7] Nullish Coalescing(双问号操作符)
// before const isBlack = params.isBlack || true// ? const isBlack = params.hasOwnProperty('isBlack') ? params.isBlack : true// ?// after const isBlack = params.isBlack ?? true// ?

[4.0] Short-Circuiting Assignment Operators(复合赋值操作符)
在 JavaScript 和许多程序语言中,称之为 Compound Assignment Operators(复合赋值操作符)
// Addition // a = a + b a += b; // Subtraction // a = a - b a -= b; // Multiplication // a = a * b a *= b; // Division // a = a / b a /= b; // Exponentiation // a = a ** b a **= b; // Left Bit Shift // a = a << b a <<= b;

新增:
a &&= b// a && (a = b) a ||= b// a || (a = b) a ??= b// a ?? (a = b)

示例:
let values: string[]; // Before (values ?? (values = [])).push("hello"); // After (values ??= []).push("hello");

声明文件 通常理解就是 .d.ts 文件,按功能可以分为:变量声明、模块声明、全局类型声明、三斜线指令等。
变量声明
假如我们想使用第三方库 jQuery,一种常见的方式是在 html 中通过标签引入 jQuery,然后就可以使用全局变量 $ 或 jQuery 了。假设要获取一个 id 为 foo 的元素。
jQuery('#foo')// ERROR: Cannot find name 'jQuery'.

TS 会报错,因为编译器不知道 $ 或 jQuery 是什么,所以需要声明这个全局变量让 TS 知道,通过 declare var 或 declare let/const 来声明它的类型。
// 声明变量 jQuery declare var jQuery: (selector: string) => any; // let 和 var 没有区别,更建议使用 let declare let jQuery: (selector: string) => any; // const 声明的变量不允许被修改 declare const jQuery: (selector: string) => any;

声明函数
// 声明函数 declare function greet(message: string): void; // 使用 greet('hello')

声明类
// 声明类 declare class Animal { name: string; constructor(name: string); sayHi(): string; }// 使用 const piggy = new Animal('佩奇') piggy.sayHi()

声明对象
// 声明对象 declare const jQuery: { version: string ajax: (url: string, settings?: any) => void }// 使用 console.log(jQuery.version) jQuery.ajax('xxx')

还可以使用 namespace 命名空间来声明对象,早期 namespace 的出现是为了解决模块化而创造的关键字,随着 ES6 module 关键字的出现,为了避免功能混淆,现在建议不使用。
declare namespace jQuery { const version: string function ajax(url: string, settings?: any): void; }

模块声明
通常我们引入 npm 包,它的声明文件可能来源于两个地方:
  • 包内置的类型文件,package.json 的 types 入口。
  • 安装 @types/xxx 对应的包类型文件。
假如上面两种方式都没有找到对应的声明文件,那么就需要手动为它写声明文件了,通过 declare module 来声明模块。
实例:手动修复 @alipay/h5data 的类型支持。
interface H5DataOption { env: 'dev' | 'test' | 'pre' | 'prod'; autoCache: boolean; }declare module '@alipay/h5data' { export function fetchData( path: string, option?: Partial, ): Promise; }// 使用 import { fetchData } from '@alipay/h5data'const res = await fetchData<{ data: 'xxx' }>('url/xxx')

拓展模块类型 某些情况下,模块已经有类型声明文件了,但引入了一些插件,插件没有支持类型,这时就需要扩展模块的类型。还是通过 declare module 扩展,因为模块声明的类型会合并。
declare module 'moment' { export function foo(): string }// 使用 import moment from 'moment' import 'moment-plugin'moment.foo()

全局类型声明
类型的作用域 在 Typescript 中,只要文件存在 import 或 export 关键字,都被视为模块文件。也就是不管 .ts 文件还是 .d.ts 文件,如果存在上述关键字之一,则类型的作用域为当前文件;如果不存在上述关键字,文件内的变量、函数、枚举等类型都是以全局作用域存在于项目中的。
全局作用域声明全局类型 全局作用域内声明的类型皆为全局类型。
局部作用域声明全局类型 局部作用域内可以通过 declare global 声明全局类型。
import type { MarketingType } from '@/constants'declare global { interface PageProps { layoutProps: { marketingType: MarketingType; isAgencyRole: boolean; }; } }

三斜线指令
三斜线指令必须放在文件的最顶端,三斜线指令的前面只允许出现单行或多行注释。
三斜线指令的作用是为了描述模块之间的依赖关系,通常情况下并不会用到,不过在以下场景,还是比较有用。
  • 当在书写一个依赖其他类型的全局类型声明文件时
  • 当需要依赖一个全局变量的声明文件时
  • 当处理编译后 .d.ts 文件丢失的问题
当需要书写一个依赖其他类型的全局类型声明文件时 在全局变量的声明文件中,是不允许出现 import, export 关键字的,一旦出现了,那么当前的声明文件就不再是全局类型的声明文件了,所以这时就需要用到三斜线指令。
/// declare function foo(options: JQuery.AjaxSettings): string;

依赖一个全局变量的声明文件
当需要依赖一个全局变量的声明文件时,由于全局变量不支持通过 import 导入,所以就需要使用三斜线指令来引入了。
/// export function foo(p: NodeJS.Process): string;

当处理编译后 .d.ts 文件丢失的问题 在写项目的时候,项目里编写的 .d.ts 文件在 tsc 编译后,并不会放置到对应的 dist 目录下,这时候就需要手动指定依赖的全局类型。
/// // ValueOf 来自 global.d.ts export declare type ComplexOptions = ValueOf;

reference
  • path: 指定类型文件的路径
  • types: 指定类型文件对应的包,例如 对应的类型文件是
参考
TypeScript Handbook:https://www.typescriptlang.or...
TypeScript Learning: https://github.com/Barrior/ty...
你不知道的 TypeScript 高级技巧:https://www.infoq.cn/article/...
TypeScript 入门教程:https://ts.xcatliu.com/basics...
读懂类型体操:TypeScript 类型元编程基础入门:https://zhuanlan.zhihu.com/p/...
JavaScript 元编程:https://chinese.freecodecamp....
其他资料

    推荐阅读