TypeScript的另一面:类型编程(2)

  • 参数obj

  • 参数key

  • 返回值

这三样之间是否存在关联?

  • key必然是obj中的键值名之一,一定为string类型

  • 返回的值一定是obj 中的键值

因此我们初步得到这样的结果:

function pickSingleValue(obj: T, key: keyof T) {

return obj[key];

}

keyof 是 索引类型查询的语法, 它会返回后面跟着的类型参数的键值组成的字面量类型(literal types),举个例子:

interface foo {

a: number;

b: string;

}

type A = keyof foo; // “a” | “b”

是不是就像Object.keys()?

字面量类型是对类型的进一步限制,比如你的状态码只可能是 0/1/2,那么你就可以写成status: 0 | 1 | 2的形式。

字面量类型包括字符串字面量数字字面量布尔值字面量

这一类细碎的基础知识会被穿插在文中各个部分进行讲解,以此避免单独讲解时缺少特定场景让相关概念显得过于单调。

还少了返回值,如果你此前没有接触过此类语法,应该会卡住,我们先联想下for...in语法,遍历对象时我们可能会这么写:

const fooObj = { a: 1, b: “1” };

for (const key in fooObj) {

console.log(key);

console.log(fooObj[key]);

}

和上面的写法一样,我们拿到了 key,就能拿到对应的 value,那么 value 的类型也就不在话下了:

function pickSingleValue(obj: T, key: keyof T): T[keyof T] {

return obj[key];

}

这一部分可能不好一步到位理解,解释下:

interface T {

a: number;

b: string;

}

type TKeys = keyof T; // “a” | “b”

type PropAType = T[“a”]; // number

你用键名可以取出对象上的键值,自然也就可以取出接口上的键值(也就是类型)啦~

但这种写法很明显有可以改进的地方:keyof出现了两次,以及泛型 T 应该被限制为对象类型,就像我们平时会做的那样:用一个变量把多处出现的存起来,在类型编程里,泛型就是变量

function pickSingleValue<T extends object, U extends keyof T>(

obj: T,

key: U

): T[U] {

return obj[key];

}

这里又出现了新东西extends… 它是啥?你可以暂时把T extends object理解为T 被限制为对象类型U extends keyof T理解为泛型 U 必然是泛型 T 的键名组成的联合类型(以字面量类型的形式,比如T的键包括a b c,那么U的取值只能是"a" “b” "c"之一)。具体的知识我们会在下一节条件类型讲到。

假设现在不只要取出一个值了,我们要取出一系列值,即参数2将是一个数组,成员均为参数1的键名组成:

function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {

return keys.map((key) => obj[key]);

}

// pick(obj, [‘a’, ‘b’])

有两个重要变化:

  • keys: U[] 我们知道 U 是 T 的键名组成的联合类型,那么要表示一个内部元素均是 T 键名的数组,就可以使用这种方式,具体的原理请参见下文的 分布式条件类型 章节。

  • T[U][] 它的原理实际上和上面一条相同,首先是T[U],代表参数1的键值(就像Object[Key]),之所以单独拿出来是因为我认为它是一个很好地例子,表现了 TS 类型编程的组合性,你不感觉这种写法就像搭积木一样吗?

索引签名 Index Signature

索引签名用于快速建立一个内部字段类型相同的接口,如

interface Foo {

}

那么接口 Foo 就被认定为字段全部为 string 类型。

等同于Record<string, string>

值得注意的是,由于 JS 可以同时通过数字与字符串访问对象属性,因此keyof Foo的结果会是string | number

const o: Foo = {

1: “芜湖!”,

};

o[1] === o[“1”]; // true

但是一旦某个接口的索引签名类型为number,那么使用它的对象就不能再通过字符串索引访问,如o['1'],将会抛出Element implicitly has an 'any' type because index expression is not of type 'number'错误。

映射类型 Mapped Types

映射类型同样是类型编程的重要底层组成,通常用于在旧有类型的基础上进行改造,包括接口包含字段、字段的类型、修饰符(只读readonly 与 可选?)等等。

从一个简单场景入手:

interface A {

a: boolean;

b: string;

c: number;

d: () => void;

}

现在我们有个需求,实现一个接口,它的字段与接口 A 完全相同,但是其中的类型全部为 string,你会怎么做?直接重新声明一个然后手写吗?这样就很离谱了,我们可是机智的程序员。

如果把接口换成对象再想想,假设要拷贝一个对象(假设没有嵌套),new 一个新的空对象,然后遍历原先对象的键值对来填充新对象。再回到接口,其实也一样:

type StringifyA = {

};

是不是很熟悉?重要的就是这个in操作符,你完全可以把它理解为for...in/for...of这种遍历的思路,获取到键名之后,键值就简单了!

type Clone = {

};

掌握这种思路,其实你已经接触到一些工具类型的底层实现了:

你可以把工具类型理解为你平时放在 utils 文件夹下的公共函数,提供了对公用逻辑(在这里则是类型编程逻辑)的封装,比如上面的两个类型接口就是~

先写个最常用的Partial尝尝鲜,工具类型的详细介绍我们会在专门的章节展开:

// 将接口下的字段全部变为可选的

type Partial = {

K in keyof T?: T[k];

};

是不是特别简单,让你已经脱口而出“就这!”,类似的,还可以实现个Readonly,把接口下的字段全部变为只读的。

条件类型 Conditional Types


条件类型的语法实际上就是三元表达式,看一个最简单的例子:

T extends U ? X : Y

如果你觉得这里的 extends 不太好理解,可以暂时简单理解为 U 中的属性在 T 中都有。

为什么会有条件类型?可以看到通常条件类型通常是和泛型一同使用的,联想到泛型的使用场景,我想你应该明白了些什么。对于类型无法即时确定的场景,使用条件类型来在运行时动态的确定最终的类型(运行时可能不太准确,或者可以理解为,你提供的函数被他人使用时,根据他人使用时传入的参数来动态确定需要被满足的类型约束)。

条件类型理解起来更直观,唯一需要有一定理解成本的就是 何时条件类型系统会收集到足够的信息来确定类型,也就是说,条件类型有时不会立刻完成判断。

在了解这一点前,我们先来看看条件类型常用的一个场景:泛型约束,实际上就是我们上面的例子:

function pickSingleValue<T extends object, U extends keyof T>(

obj: T,

key: U

): T[U] {

return obj[key];

}

这里的T extends objectU extends keyof T都是泛型约束,分别将 T 约束为对象类型 和 将 U 约束为 T 键名的字面量联合类型。我们通常使用泛型约束来 收窄类型约束

以一个使用条件类型作为函数返回值类型的例子:

declare function strOrNum(

x: T

): T extends true ? string : number;

在这种情况下,条件类型的推导就会被延迟,因为此时类型系统没有足够的信息来完成判断。

只有给出了所需信息(在这里是入参x的类型),才可以完成推导。

const strReturnType = strOrNum(true);

const numReturnType = strOrNum(false);

同样的,就像三元表达式可以嵌套,条件类型也可以嵌套,如果你看过一些框架源码,也会发现其中存在着许多嵌套的条件类型,无他,条件类型可以将类型约束收拢到非常精确的范围内。

type TypeName = T extends string

? “string”

: T extends number

? “number”

: T extends boolean

? “boolean”

: T extends undefined

? “undefined”

: T extends Function

? “function”

: “object”;

分布式条件类型 Distributive Conditional Types

官方文档对分布式条件类型的讲解内容甚至要多于条件类型,因此你也知道这玩意没那么简单了吧~

分布式条件类型实际上不是一种特殊的条件类型,而是其特性之一。先上概念:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上

原文: Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation

先提取几个关键词,然后我们再通过例子理清这个概念:

  • 裸类型参数

  • 实例化

  • 分发到联合类型

// 使用上面的TypeName类型别名

// “string” | “function”

type T1 = TypeName<string | (() => void)>;

// “string” | “object”

type T2 = TypeName<string | string[]>;

// “object”

type T3 = TypeName<string[] | number[]>;

我们发现在上面的例子里,条件类型的推导结果都是联合类型(T3 实际上也是,只不过相同所以被合并了),并且其实就是类型参数被依次进行条件判断后,再使用|组合得来的结果。

是不是 get 到了一点什么?我们再看另一个例子:

type Naked = T extends boolean ? “Y” : “N”;

type Wrapped = [T] extends [boolean] ? “Y” : “N”;

/*

* 先分发到 Naked | Naked

* 所以结果是"N" | “Y”

*/

type Distributed = Naked<number | boolean>;

/*

* 不会分发 直接是 [number | boolean] extends [boolean]

* 这样当然就是"N"啦~

*/

type NotDistributed = Wrapped<number | boolean>;

现在我们可以来讲讲这几个概念了:

  • 裸类型参数,没有额外被接口/类型别名/奇怪的东西包裹过的,就像被Wrapped包裹后就不能再被称为裸类型参数。

  • 实例化,其实就是条件类型的判断过程,就像我们前面说的,条件类型需要在收集到足够的推断信息之后才能进行这个过程。在这里两个例子的实例化过程实际上是不同的,具体会在下一点中介绍。

  • 分发至联合类型的过程:

    • 对于 TypeName,它内部的类型参数 T 是没有被包裹过的,所以TypeName<string | (() => void)>会被分发为TypeName<string> | TypeName<(() => void)>,然后再次进行判断,最后分发为"string" | "function"
  • 抽象下具体过程:

( A | B | C ) extends T ? X : Y

// 相当于

(A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)

一句话概括:没有被额外包装的联合类型参数,在条件类型进行判定时会将联合类型分发,分别进行判断。

infer 关键字


inferinference的缩写,通常的使用方式是infer RR表示 待推断的类型。如果说,通常infer不会被直接使用,而是与条件类型一起,被放置在底层工具类型中,用于

看一个简单的例子,用于获取函数返回值类型的工具类型ReturnType:

const foo = (): string => {

return “linbudu”;

};

// string

type FooReturnType = ReturnType;

infer的使用思路可能不是那么好习惯,我们可以用前端开发中常见的一个例子类比,页面初始化时先显示占位交互,像 Loading/骨架屏,在请求返回后再去渲染真实数据。infer也是这个思路,类型系统在获得足够的信息后,就能将 infer 后跟随的类型参数推导出来,最后返回这个推导结果。

type ReturnType = T extends (…args: any[]) => infer R ? R : any;

  • (...args: any[]) => infer R 是一个整体,这里函数的返回值类型的位置被infer R占据了。

  • ReturnType被调用,泛型T被实际类型填充,如果T满足条件类型的约束,就返回R的值,在这里R即为函数的返回值实际类型。

  • 实际上为了严谨,应当约束泛型T为函数类型,即:

type ReturnType<T extends (…args: any) => any> = T extends (…args: any) => infer R ? R : any;

类似的,借着这个思路我们还可以获得函数入参类型、类的构造函数入参类型、甚至Promise 内部的类型等,这些工具类型我们会在后面讲到。

infer 其实没有特别难消化的知识点,它需要的只是思路的转变,你要理解 延迟推断 的概念。

类型守卫 与 is in 关键字 Type Guards


前面的内容可能不是那么符合人类直觉,需要一点时间消化,这一节我们来看点简单(相对)且直观的知识点:类型守卫。

假设有这么一个字段,它可能字符串也可能是数字:

numOrStrProp: number | string;

现在在使用时,你想将这个字段的联合类型缩小范围,比如精确到string,你可能会这么写:

export const isString = (arg: unknown): boolean => typeof arg === “string”;

看看这么写的效果:

function useIt(numOrStr: number | string) {

if (isString(numOrStr)) {

console.log(numOrStr.length);

}

}

image

啊哦,看起来isString函数并没有起到缩小类型范围的作用,参数依然是联合类型。这个时候就该使用is关键字了:

export const isString = (arg: unknown): arg is string =>

typeof arg === “string”;

这个时候再去使用,就会发现在isString(numOrStr)为 true 后,numOrStr的类型就被缩小到了string。这只是以原始类型为成员的联合类型,我们完全可以扩展到各种场景上,先看一个简单的假值判断:

export type Falsy = false | “” | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

是不是还挺有用?这应该是我日常用的最多的类型别名之一了。

也可以在 in 关键字的加持下,进行更强力的类型判断,思考下面这个例子,要如何将 " A | B " 的联合类型缩小到"A"?

class A {

public a() {}

public useA() {

return “A”;

}

}

class B {

public b() {}

public useB() {

return “B”;

}

}

再联想下for...in循环,它遍历对象的属性名,而in关键字也是一样:

function useIt(arg: A | B): void {

‘a’ in arg ? arg.useA() : arg.useB();

}

如果参数中存在a属性,由于A、B两个类型的交集并不包含a,所以这样能立刻缩小范围到A。

再看一个使用字面量类型作为类型守卫的例子:

interface IBoy {

name: “mike”;

gf: string;

}

interface IGirl {

name: “sofia”;

bf: string;

}

function getLover(child: IBoy | IGirl): string {

if (child.name === “mike”) {

return child.gf;

} else {

return child.bf;

}

}

之前有个小哥问过一个问题,我想很多用 TS 写接口的小伙伴可能都遇到过,即登录与未登录下的用户信息是完全不同的接口,其实也可以使用in关键字解决。

interface ILogInUserProps {

isLogin: boolean;

name: string;

}

interface IUnLoginUserProps {

isLogin: boolean;

from: string;

}

type UserProps = ILogInUserProps | IUnLoginUserProps;

function getUserInfo(user: ILogInUserProps | IUnLoginUserProps): string {

return ‘name’ in user ? user.name : user.from;

}

同样的思路,还可以使用instanceof来进行实例的类型守卫,建议聪明的你动手尝试下~

工具类型 Tool Type


这一章是本文的最后一部分,应该也是本文“性价比”最高的一部分了,因为即使你还是不太懂这些工具类型的底层实现,也不影响你把它用好。就像 Lodash 不会要求你每用一个函数都熟知原理一样。这一部分包括TS 内置工具类型与社区的扩展工具类型,我个人推荐在完成学习后记录你觉得比较有价值的工具类型,并在自己的项目里新建一个.d.ts文件(或是/utils/tool-types.ts)存储它。

在继续阅读前,请确保你掌握了上面的知识,它们是类型编程的基础。

内置工具类型

在上面我们已经实现了内置工具类型中被使用最多的一个:

type Partial = {

K in keyof T?: T[k];

};

它用于将一个接口中的字段变为全部可选,除了映射类型以外,它只使用了?可选修饰符,那么我现在直接掏出小抄(好家伙):

  • 去除可选修饰符:-?

  • 只读修饰符:readonly

  • 去除只读修饰符:-readonly

恭喜,你得到了RequiredReadonly(去除 readonly 修饰符的工具类型不属于内置的,我们会在后面看到):

type Required = {

K in keyof T-?: T[K];

};

type Readonly = {

readonly K in keyof T: T[K];

};

在上面我们实现了一个 pick 函数:

function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {

return keys.map((key) => obj[key]);

}

照着这种思路,假设我们现在需要从一个接口中挑选一些字段:

type Pick<T, K extends keyof T> = {

};

// 期望用法

type Part = Pick<A, “a” | “b”>;

还是映射类型,只不过现在映射类型的映射源是类型参数K

既然有了Pick,那么自然要有Omit(一个是从对象中挑选部分,一个是排除部分),它和Pick的写法非常像,但有一个问题要解决:我们要怎么表示T中剔除了K后的剩余字段?

Pick 选取传入的键值,Omit 移除传入的键值

这里我们又要引入一个知识点:never类型,它表示永远不会出现的类型,通常被用来将收窄联合类型或是接口,详细可以看 尤大的知乎回答[6], 在这里 我们不做展开介绍。

在类型守卫一节,我们提到了一个用户登录状态决定类型接口的例子,实际上也可以用never实现。

上面的场景其实可以简化为:

// “3” | “4” | “5”

type LeftFields = Exclude<“1” | “2” | “3” | “4” | “5”, “1” | “2”>;

Exclude,字面意思看起来是排除,那么第一个参数应该是要进行筛选的,第二个应该是筛选条件!先按着这个思路试试:

用排列组合的思路考虑:"1""1" | "2"里面吗("1" extends "1"|"2" -> true)?在啊, 那让它爬,"3"在吗?不在那就让它留下来。

这里实际上使用到了分布式条件类型的特性,假设 Exclude 接收 T U 两个类型参数,T 联合类型中的类型会依次与 U 类型进行判断,如果这个类型参数在 U 中,就剔除掉它(赋值为 never)

type Exclude<T, U> = T extends U ? never : T;

那么 Omit:

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

剧透下,几乎所有使用条件类型的场景,把判断后的赋值语句反一下,就会有新的场景,比如Exclude移除掉键名,那反一下就是保留键名:

type Extract<T, U> = T extends U ? T : never;

再来看个常用的工具类型Record<Keys, Type>,通常用于生成以联合类型为键名(Keys),键值类型为Type的新接口,比如:

type MyNav = “a” | “b” | “b”;

interface INavWidgets {

widgets: string[];

title?: string;

keepAlive?: boolean;

}

const router: Record<MyNav, INavWidgets> = {

a: { widget: [“”] },

b: { widget: [“”] },

c: { widget: [“”] },

};

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注:前端)
img

最后

为了帮助大家更好的了解前端,特别整理了《前端工程师面试手册》电子稿文件。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

Record<Keys, Type>,通常用于生成以联合类型为键名(Keys),键值类型为Type的新接口,比如:

type MyNav = “a” | “b” | “b”;

interface INavWidgets {

widgets: string[];

title?: string;

keepAlive?: boolean;

}

const router: Record<MyNav, INavWidgets> = {

a: { widget: [“”] },

b: { widget: [“”] },

c: { widget: [“”] },

};

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-4KJr0olB-1710693368417)]
[外链图片转存中…(img-NCbMGBsD-1710693368418)]
[外链图片转存中…(img-Dtfg67kH-1710693368419)]
[外链图片转存中…(img-HUDJOGg3-1710693368419)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注:前端)
[外链图片转存中…(img-v2BqJcUw-1710693368419)]

最后

为了帮助大家更好的了解前端,特别整理了《前端工程师面试手册》电子稿文件。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

  • 16
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值