Skip to content

📖唠唠 TypeScript 类型编程中的泛型用法与案例实践

(一)前言

泛型(Generics)作为 TypeScript 中的一项核心特性,允许开发者编写出更加灵活、可重用的代码。泛型不仅能够提升类型安全性,还能在不牺牲性能的前提下,实现代码的复用。

下边结合自己看过和实际项目中类型编程实践后的体验,总结汇总下 TypeScript 类型编程中泛型(Generics)的一些实用技巧和案例实践。

本篇是继 唠唠 TypeScript 分布式条件类型与 infer 及应用 之后的第2篇关于 TypeScript 内容的文章,更多也是对自己阶段学习成果的整理输出,也希望文中提到的一些内容或思路能够给大家些借鉴。

(二)泛型用法介绍

1. 泛型引入

你可能要问,泛型有没有严格的定义,当然有,详细可以看看这里:Generic programming

而简单地说,泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

下边结合🌰来理解下。

假设现在有一个函数 echo,作用是参数传入的是什么值,返回的就是什么值,对应于类型层面上,参数是什么类型,返回值就是什么类型,此时这个函数可能如下写:

typescript
function (: number): number {
  return 
}

(123)
// echo('123') error: Argument of type 'string' is not assignable to parameter of type 'number'.

但如上函数只能接收 number 类型的参数,其他类型参数传入会出现类型报错,因此该函数可能会被改写为:

typescript
function (: any): any {
  return 
}

(123)
('123')

此时,因为 any 类型是通用的,因此函数能接受 arg 类型的任何和所有类型,类型上不会报错了。但实际上,当函数返回时,将丢失有关该类型的信息,参数与返回值之间的类型关系反映不出来了,即不管传入的是什么类型的值,返回值的类型都是 any

为了完美地实现该 echo 函数,可以使用泛型,落实到编码上就是增加泛型参数(或者叫类型参数):

typescript
function <>(: ):  {
  return 
}

<number>(123)
<string>('123')
<boolean>(true)

如上,给 echo 函数增加了泛型参数 T,然后在调用 echo 函数时传入具体的参数类型,这样改写之后,函数的参数跟返回值的类型就保持一致了。

不过,对于在函数中使用泛型的这种场景,为了方便,函数调用时,往往省略不写泛型参数的值,让 TypeScript 自己推断:

typescript
function <>(: ):  {
  return 
}

(123)
('123')
(true)

不过此时你会发现,当不传入参数类型时,echo 函数上的类型信息会被推断到尽可能精确的程度,如这里会推导到字面量类型而不是基础类型,比如上述🌰推断出来的泛型参数 T 分别是 123"123"true

泛型参数的命名,可以随便取,但是必须为合法的标识符。编码习惯上,泛型参数的第1个字符往往采用大写字母。一般会使用T(type 的第一个字母)作为泛型参数的名字。如果有多个泛型参数,则还习惯使用的有 UVPK 等字母命名,各个参数之间使用英文逗号 , 分隔。

这里仅做下泛型的简单引入,关于在函数上使用泛型的更多用法,以及如何在接口或类上使用泛型,阮一峰大神的 TypeScript 教程写得很好了,详细可见:阮一峰 TypeScript 教程-TypeScript 泛型

下边侧重唠唠类型别名中的泛型用法,这在 TypeScript 的类型编程中十分常见。

2. 类型别名中的泛型用法

代码示例汇总:Playground Link

首先来个简单🌰:

typescript
type <> =  | number | string

type  = <123>
type  = <boolean>

上面这个类型别名 UnionType 的本质上可看成是一个函数,T 就是它的变量,返回值则是一个包含 T 的联合类型,伪代码形式可以为:

typescript
function UnionType(typeArg) {
  return [typeArg, number, string]
}

类型别名中的泛型,绝大多数时候是用来进行工具类型封装的,比如如下几个 TypeScript 内置的工具类型:

typescript
type Required<T> = { [P in keyof T]-?: T[P]; }

type Readonly<T> = { readonly [P in keyof T]: T[P]; }

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }

对应的🌰如下:

typescript
type  = {
  : string
  : number
  : boolean
  ?: string[]
}

type  = <>
type  = <>
type  = <, 'name' | 'age'>

除此之外,在条件类型中使用泛型的场景也是非常多的:

typescript
type  = (...: any[]) => any

type < extends > =  extends (...: any[]) => string ? true : false

type  = <(: string) => string> // true
type  = <(: string) => boolean> // false
type  = <() => number> // false

如上例子,可以通过 T extends (...args: any[]) => string ? true : false 条件类型判断一个函数的返回值类型是否是 string,这里用到了泛型参数 T

如上的🌰中用到了 T extends U 这种形式的泛型参数,即可以使用 extends 关键字约束泛型参数的类型,当传入的参数类型不符合约束条件时,TypeScript 编译时就会出现类型报错:

typescript
type  = (...: any[]) => any

type < extends > =  extends (...: any[]) => string ? true : false

type  = <(: string) => string> // true

type  = 234 extends  ? true : false
// type Res7 = FuncReturnString<234> error: Type 'number' does not satisfy the constraint 'FuncType'.

对于上述示例,因为 IsFunc 的类型结果为 false,不满足 extends FuncType 的类型约束条件,因此 FuncReturnString 会出现类型报错。

再来看个🌰:

typescript
type  = {
  : string
  : number
  : boolean
  ?: string[]
}

type  = <, 'name' | 'age'>

type  = keyof  & {}
type  = 'name' | 'gender' extends  ? true : false
// type UserName3 = Pick<UserName, 'name' | 'gender'> error: Type '"name" | "gende"' does not satisfy the constraint 'keyof UserName'.

对于上述示例,因为 IsKeyofObj 的类型结果为 false,不满足 extends keyof T (这里的 keyof T 即为上边的 KeyofUserName)的类型约束条件,因此 Pick 会出现类型报错。

唠完了泛型的类型约束,接下来看看泛型的类型参数默认值

我们对 TypeScript 内置的 Pick 工具类型改造一下,如果我们使用 Pick 时不传入第2个泛型参数,则默认把所有的 K 提取出来,此时写法如下:

typescript
type <,  extends keyof  = keyof > = { 
  [ in ]: []; 
}

type  = {
  : string
  : number
  : boolean
  ?: string[]
}

type  = <, 'name' | 'age'>
type  = <>

如上,我们在 Pick2 的第2个泛型参数加入了 =,给参数设置了默认值 keyof T,如此设置,当第2个参数类型不传入时,K 参数默认使用 keyof T 类型。

再来看个🌰:

typescript
type < extends string = 'World'> = `Hello ${}`

type  = <'TypeScript'>
type  = 

当传入 "TypeScript" 时,结果为 "Hello TypeScript",否则 Str 泛型参数默认使用类型 "World",结果为 "Hello World"

关于类型参数默认值,需要遵循一些规则,详细可见:泛型参数默认值规则

(三)类型编程案例实践

好了,泛型的用法,尤其是类型别名中的泛型用法介绍完了,接下来,结合实际项目或 type-challenges 上的类型编程实践,汇总罗列些实用的工具类型案例。

类型编程中,基本上,泛型是少不了,而且常常会与一些高阶的工具类型(比如联合类型、交叉类型、索引类型、映射类型和条件类型等)结合一起使用。

1. 泛型与联合/交叉类型的结合

代码示例汇总:Playground Link

泛型与联合/交叉类型的结合,绝大多数使用场景是做类型的合并。

来看一些案例:

typescript
interface PersonItem {
  : string
  : number
  : boolean
  ?: string[]
}

type <> = PersonItem & 

type  = <{ 
  : number
  : string
}>

type  = <{ 
  : number
  : number
}>

type <> = { 
	[ in keyof ]: []
}

type  = <>
type  = <>

如上,我们通过使用 Combine 工具类型(配合泛型 T 和交叉类型),对 PersonItem 的类型做了扩充,创建出了 TeacherItemStudentItem 类型。而为了能够看到这2个类型的具体字段,这里还使用了 Flatten ,扁平化传入的类型。

typescript
type MaybeRef<T> = T | Ref<T>
type MaybeRefOrGetter<T> = MaybeRef<T> | (() => T)
type ReadonlyRefOrGetter<T> = ComputedRef<T> | (() => T)

如上这 3 个是 VueUse 内部使用最多的工具类型(配合泛型 T 和联合类型),为了使 composable 函数能够兼容更多的参数输入情况,即考虑输入的是原始值,或 ref 函数或 computed 函数处理过后的值,可以根据使用场景,利用这 3 个给对应参数做类型声明。

除此之外,还有几个实用的工具类型封装:

typescript
// 表示该类型的值不能为空
type NonNullable<T> = T extends null | undefined ? never : T

// 表示该类型的值可以为空
type Nullable<T> = T | null

// 类型可能为数组类型
type MaybeArray<T> = T | T[]

// 并集
type Concurrence<A, B> = A | B

2. 泛型与索引/映射类型的结合

代码示例汇总:Playground Link

泛型与索引/映射类型的结合,在类型编程中,还算是比较多的。

typescript
type Required<T> = { [P in keyof T]-?: T[P]; }

type Readonly<T> = { readonly [P in keyof T]: T[P]; }

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }

如上这几个是 TypeScript 内置的工具类型,无一例外的都使用了泛型配合索引/映射类型来实现,使用示例如下:

typescript
type  = {
  : string
  : number
  : boolean
  ?: string[]
}

type  = <>
type  = <>
type  = <, 'name' | 'age'>

然后来看几个实用的工具类型封装:

typescript
type <,  extends string, > =  & { 
  [ in ]:  
}

interface Product {
  : number
  : string
  : number
}

type  = <Product, 'discount', number>

const :  = {
  : 1,
  : 'Laptop',
  : 999,
  : 0.1
}

如上的 AddProperty 工具类型允许将一个新属性添加到现有的类型中,而不影响其他属性。

typescript
type <, > = {
  [ in keyof ]: 
}

interface Person {
  : string
  : number
}

type  = <Person, boolean>

const :  = {
  : false,
  : true
}

如上的 MapProps 工具类型会创建一个类型,将原始类型的每个属性映射到一个新的类型。

3. 泛型与条件类型的结合

代码示例汇总:Playground Link

泛型与条件类型的使用,使用频率是最高的,案例也是最多的。

typescript
type <> = [] extends [never] ? true : false
type  = <never> // true
type  = <any> // false
type  = <boolean> // false

type <> = 1 extends  & 2 ? true : false
type  = <any> // true
type  = <never> // false
type  = <boolean> // false

type <> = unknown extends 
? <> extends true
  ? false
  : true
: false
type  = <unknown> // true
type  = <any> // false
type  = <number> // false

上述利用泛型跟条件类型的结合,封装了3个分别用来判断 neveranyunknown 类型的工具类型,而关于这3个工具类型的分析,可以看看我的另外1篇文章:唠唠 TypeScript 分布式条件类型与 infer 及应用-分布式条件类型

typescript
// 交集
type <, > =  extends  ?  : never
// 差集
type <, > =  extends  ? never : 
// 补集
type <,  extends > = <, >


type  = 1 | 2 | 3 | 4
type  = 1 | 2 | 4

type  = <, > // 1 | 2 | 4
type  = <, > // 3
type  = <, > // 3

由于条件类型分布式特性的存在,如上的 Intersection<A, B> 交集类型操作其实可以看成:

typescript
type  = 
| (1 extends 1 | 2 | 4 ? 1 : never)
| (2 extends 1 | 2 | 4 ? 2 : never)
| (3 extends 1 | 2 | 4 ? 3 : never)
| (4 extends 1 | 2 | 4 ? 4 : never)

类似地,Difference<A, B> 差集类型操作可以看成:

typescript
type  = 
| (1 extends 1 | 2 | 4 ? never : 1)
| (2 extends 1 | 2 | 4 ? never : 2)
| (3 extends 1 | 2 | 4 ? never : 3)
| (4 extends 1 | 2 | 4 ? never : 4)

剩下的 Complement<A, B> 补集类型操作只是在 Difference<A, B> 的基础上,对传入的泛型参数做了类型约束而已。

最后,再来罗列几个 TypeScript 内置工具类型的实现:

ts
type FunctionType = (...args: any) => any
type ClassType = abstract new (...args: any) => any

type Parameters<T extends FunctionType> = T extends (...args: infer P) => any ? P : never

type ReturnType<T extends FunctionType> = T extends (...args: any) => infer R ? R : never

type ConstructorParameters<T extends ClassType> = T extends abstract new (
  ...args: infer P
) => any
  ? P
  : never

type InstanceType<T extends ClassType> = T extends abstract new (
  ...args: any
) => infer R
  ? R
  : any

如上代码配合使用了泛型、条件类型和 infer 关键字,来完成类型的封装。关于 infer 关键字的介绍,可以看看我的另外1篇文章:唠唠 TypeScript 分布式条件类型与 infer 及应用-infer 介绍

如上几个内置工具类型对应的使用示例如下:

typescript
function (: string, : number) {
  return  + 
}

type  = <typeof > // [a: string, b: number]
type  = <typeof > // string

class  {
  constructor(: string, : number, : boolean) {}
}

type  = <typeof > // [name: string, age: number, male: boolean]
type  = <typeof > // Person

当然了,泛型跟如上多种类型的综合使用,也是可以演变成很多写法的,具体,后边再写篇文章单独唠唠。

案例会持续更新!

(四)总结

本文结合一些示例介绍了泛型用法,尤其侧重介绍了类型别名中泛型的用法,然后结合自己的类型编程实践给出了一些实用的工具类型实现,希望对大家有点帮助!

最后,想给大家推荐2个 Github 仓库,罗列如下:

以上,欢迎各位大佬勘误~

Happy Coding!

(五)参考资料