TypeScript类型体操训练(三)

776 阅读5分钟

开始之前

前两章我们实现了PickExclude,并且学习了inkeyofextends等等关键字的用法。本章我们将这些结合起来,实现更为复杂的类型,然后再探寻新的解决方式。

Omit

首先我们将要实现的是Omit,相信熟悉TS的同学都知道它的用法了:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyOmit<Todo, 'description' | 'title'>

const todo: TodoPreview = {
  completed: false,
}

作用是从一个接口中剔除一个或多个属性。相信大家已经都可以做出来了。

不过这里要说的重点是结合的解法,也就是通过多个函数的组合来实现功能。

第一章我们说过,TypeScript的类型系统可以看作是一门独立的函数式语言,所以我们当然可以用函数式语言的思想来实现类型。

下面来分析一下Omit,首先最后的结果是从给定的接口里返回一个它的子集,我们自然而然就想到了使用Pick

type MyOmit<T, U> = Pick<T, ?>

第二个参数是我们将要pick的键,应该pick哪些键呢?答案是除了U以外的T的键,那么我们自然而然又可以想到使用Exclude

type MyOmit<T, U> = Pick<T, Exclude<keyof T, U>>  //注意,这里要使用keyof来把T转化为联合类型

以上就是本题的标准答案。

希望大家以后写类型时,多多实践这种组合的思想,复用已有的类型工具,增加代码的可读性。

进一步探索

对于以上的实现方法,如果我们将PickExclude展开,会发生什么呢?可以一起试试:

//先展开Pick
type MyOmit<T, U> = {
    [key in Exclude<keyof T, U>]: T[key]
}

//再展开Exclude
type MyOmit<T, U> = {
    [key in T extends U ? never : T]: T[key] 
} //error

然后我们会看到报错,也就是说TS的语法决定了不能这么写。

但在TypeScript4.1的版本之后,我们可以使用Key Remapping via as来解决这个问题。

Key Remapping via as

我们可以使用as关键字来改变映射的键,你可以参考官方文档

最简单的例子:

type MappedTypeWithNewProperties<Type> = {
    [Properties in keyof Type as NewKeyType]: Type[Properties]
}

这里的Properties被重新映射成了NewKeyType,只相当于一个重命名的作用。

至此我们就可以实现MyOmit的展开了:

// 我们只是借助as进行展开,所以key名可以不变
export type MyOmit3<T, U> = {
  [key in keyof T as key extends U ? never : key]: T[key]
}

但是,它能做的远不止于此,再重新映射键名后,你可以进行各种运算,以产生新的键名。

比如,使用Exclude来产生never,借以排除该key:

type RemoveKindField<Type> = {
    [Property in keyof Type as Exclude<Property, "kind">]: Type[Property]
};
 
interface Circle {
    kind: "circle";
    radius: number;
}
 
type KindlessCircle = RemoveKindField<Circle>;
// type KindlessCircle = {
//     radius: number;
// }

你还可以把键运算成任意的union,不只是string | number | symbol,所有类型均可:

type EventConfig<Events extends { kind: string }> = {
    [E in Events as E["kind"]]: (event: E) => void;
}
 
type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };
 
type Config = EventConfig<SquareEvent | CircleEvent>
// type Config = {
//     square: (event: SquareEvent) => void;
//     circle: (event: CircleEvent) => void;
// }

你甚至可以和模板字符串类型(不知道的同学没关系,我们后面章节会讲)结合使用:

// Capitalize可以让字符串首字母变大写
type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};
 
interface Person {
    name: string;
    age: number;
    location: string;
}
 
type LazyPerson = Getters<Person>;
// type LazyPerson = {
//     getName: () => string;
//     getAge: () => number;
//     getLocation: () => string;
// }

学会这个强大的特性,对类型体操很有帮助!

Readonly系列

通过前面两章和上一节的学习,我们已经可以写出有一定复杂度的类型了,下面带大家一起完成readonly一系列的经典题目,巩固目前为止我们学到的知识。

readonly

首先是最基础的readonly,它将接口中所有属性都变成readonly

interface Todo {
  title: string
  description: string
}

const todo: MyReadonly<Todo> = {
  title: "Hey",
  description: "foobar"
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property

实现起来其实很简单,我们只需要循环出接口的每个键,然后加上前缀readonly即可:

type MyReadonly<T> = {
  readonly [key in keyof T]: T[key]
}

还记得第一章提到过的Partial吗?这类问题的解决方法都是类似的:

type Partial<T> = {
	[P in keyof T]?: T[p]
}

Mutable

既然可以给属性加上readonly,那是不是也能去掉它呢?

interface Todo {
  readonly title: string
  readonly description: string
  readonly completed: boolean
}

type MutableTodo = Mutable<Todo> // { title: string; description: string; completed: boolean; }

其实也非常简单,在它前面加上-即可:

type Mutable<T> = {
  -readonly [key in keyof T]: T[key]
}

同理,也可以去掉Partial

type Require<T> = {
    [key in keyof T]-?: T[key]
}

很简单的用法,记住即可。

readonly2

接着是有点难度的readonly2,先看下用法:

interface Todo {
  title: string
  description: string
  completed: boolean
}

const todo: MyReadonly2<Todo, "title" | "description"> = {
  title: "Hey",
  description: "foobar",
  completed: false,
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property
todo.completed = true // OK

readonly不同,此2号可以指定需要作用的键名。

也许你已经有点思路,但是开始之前,别忘记先对我们的参数进行约束,提高它的可用性:

type MyReadonly2<T, U extends keyof T> = {} //使用extends约束泛型参数

根据结合的思想,我们可以想到使用pick来把T中的U选出来并遍历,都加上readonly

type MyReadonly2<T, U extends keyof T> = {
    readonly [key in keyof Pick<T, U>]: T[key]
}

但这样是不够的,因为我们还要保留其他属性,而不是抛弃掉。

那么既然我们已经使用了结合的思想,就要继续贯彻:使用Exclude选出剩下的属性并遍历,然后原封不动的返回;再与上面结合,本题的解就出现了:

export type MyReadonly2<T, U extends keyof T> = {
  readonly [key in keyof Pick<T, U>]: T[key]
} & {
  [K in Exclude<keyof T, U>]: T[K]
}

注意,这里使用了交叉类型,忘记它的同学可以去复习一下哦!

思考题 DeepReadonly

下面来一道“作业”:DeepReadonly,下面展示下它的用法:

type X = { 
  x: { 
    a: 1
    b: 'hi'
  }
  y: 'hey'
}

type Expected = { 
  readonly x: { 
    readonly a: 1
    readonly b: 'hi'
  }
  readonly y: 'hey' 
}

type Todo = DeepReadonly<X> // should be same as `Expected`

大家可以自己尝试着解解看,先不要看答案,不过我也会在下期一起做这道题的。(完成提示:函数式思想)

那我们下期再见吧!