TypeScript的另一面:类型编程,web前端开发怎样学

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: [“”] },

};

其实很简单,把Keys的每个键值拿出来,类型规定为Type即可

// K extends keyof any 约束K必须为联合类型

type Record<K extends keyof any, T> = {

};

在前面的 infer 一节中我们实现了用于获取函数返回值的ReturnType

type ReturnType<T extends (…args: any) => any> = T extends (

…args: any

) => infer R

? R

: any;

其实把 infer 换个位置,比如放到返回值处,它就变成了获取参数类型的Parameters:

type Parameters<T extends (…args: any) => any> = T extends (

…args: infer P

) => any

? P

: never;

如果再大胆一点,把普通函数换成类的构造函数,那么就得到了获取构造函数入参类型的ConstructorParameters

type ConstructorParameters<

T extends new (…args: any) => any

= T extends new (…args: infer P) => any ? P : never;

加上new关键字来使其成为可实例化类型声明,也就是此处的泛型约束需要一个类。

这个是获得类的构造函数入参类型,如果把待 infer 的类型放到其返回处,想想 new 一个类的返回值是什么?实例!所以我们得到了实例类型InstanceType

type InstanceType<T extends new (…args: any) => any> = T extends new (

…args: any

) => infer R

? R

: any;

这几个例子看下来,你应该已经 get 到了那么一丝天机,类型编程的确没有特别高深晦涩的语法,它考验的是你对其中基础部分如索引映射条件类型的掌握程度,以及举一反三的能力。下面我们要学习的社区工具类型,本质上还是各种基础类型的组合,只是从常见场景下出发,补充了官方没有覆盖到的部分。

模板类型相关

TypeScript 4.1[7] 中引入了模板字面量类型,使得可以使用${} 这一语法来构造字面量类型,如:

type World = ‘world’;

// “hello world”

type Greeting = hello ${World};

随之而来的还有四个新的工具类型:

type Uppercase = intrinsic;

type Lowercase = intrinsic;

type Capitalize = intrinsic;

type Uncapitalize = intrinsic;

它们的作用就是字面意思,不做解释了。相关的PR见 40336[8],作者Anders Hejlsberg是C#与Delphi的首席架构师,同时也是TS的作者之一。

intrinsic代表了这些工具类型是由TS编译器内部实现的,其实也很好理解,我们无法通过类型编程来改变字面量的值,但我想按照这个趋势,TS类型编程以后会支持调用Lodash方法也说不定。

社区工具类型

这一部分的工具类型大多来自于utility-types[9],其作者同时还有react-redux-typescript-guide[10] 和 typesafe-actions[11]这两个优秀作品。

同时,也推荐type-fest[12]这个库,和上面相比更加接地气一些。其作者的作品…,我保证你直接或间接的使用过(如果不信,一定要去看看…我刚看到的时候是真的震惊的不行)。

我们由浅入深,先封装基础的类型别名和对应的类型守卫:

export type Primitive =

| string

| number

| bigint

| boolean

| symbol

| null

| undefined;

export const isPrimitive = (val: unknown): val is Primitive => {

if (val === null || val === undefined) {

return true;

}

const typeDef = typeof val;

const primitiveNonNullishTypes = [

“string”,

“number”,

“bigint”,

“boolean”,

“symbol”,

];

return primitiveNonNullishTypes.indexOf(typeDef) !== -1;

};

export type Nullish = null | undefined;

export type NonUndefined = A extends undefined ? never : A;

// 实际上TS也内置了

type NonNullable = T extends null | undefined ? never : T;

FalsyisFalsy我们已经在上面体现了~

趁着对 infer 的记忆来热乎,我们再来看一个常用的场景,提取 Promise 的实际类型:

const foo = (): Promise => {

return new Promise((resolve, reject) => {

resolve(“linbudu”);

});

};

// Promise

type FooReturnType = ReturnType;

// string

type NakedFooReturnType = PromiseType;

如果你已经熟练掌握了infer的使用,那么实际上是很好写的,只需要用一个infer参数作为 Promise 的泛型即可:

export type PromiseType<T extends Promise> = T extends Promise

? U

: never;

使用infer R来等待类型系统推导出R的具体类型。

递归的工具类型

前面我们写了个Partial Readonly Required等几个对接口字段进行修饰的工具类型,但实际上都有局限性,如果接口中存在着嵌套呢?

type Partial = {

[P in keyof T]?: T[P];

};

理一下逻辑:

  • 如果不是对象类型,就只是加上?修饰符

  • 如果是对象类型,那就遍历这个对象内部

  • 重复上述流程。

是否是对象类型的判断我们见过很多次了, T extends object即可,那么如何遍历对象内部?实际上就是递归。

export type DeepPartial = {

[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];

};

utility-types内部的实现实际比这个复杂,还考虑了数组的情况,这里为了便于理解做了简化,后面的工具类型也同样存在此类简化。

那么DeepReadobly、 DeepRequired也就很简单了:

export type DeepMutable = {

-readonly [P in keyof T]: T[P] extends object ? DeepMutable<T[P]> : T[P];

};

// 即DeepReadonly

export type DeepImmutable = {

+readonly [P in keyof T]: T[P] extends object ? DeepImmutable<T[P]> : T[P];

};

export type DeepRequired = {

[P in keyof T]-?: T[P] extends object | undefined ? DeepRequired<T[P]> : T[P];

};

尤其注意下DeepRequired,它的条件类型判断的是 T[P] extends object | undefined,因为嵌套的对象类型可能是可选的(undefined),如果仅使用object,可能会导致错误的结果。

另外一种省心的方式是不进行条件类型的判断,直接全量递归所有属性~

返回键名的工具类型

在有些场景下我们需要一个工具类型,它返回接口字段键名组成的联合类型,然后用这个联合类型进行进一步操作(比如给 Pick 或者 Omit 这种使用),一般键名会符合特定条件,比如:

  • 可选/必选/只读/非只读的字段

  • (非)对象/(非)函数/类型的字段

来看个最简单的函数类型字段FunctionTypeKeys

export type FunctTypeKeys = {

[K in keyof T]-?: T[K] extends Function ? K : never;

}[keyof T];

{ [K in keyof T]: ... }[keyof T]这个写法可能有点诡异,拆开来看:

interface IWithFuncKeys {

a: string;

b: number;

c: boolean;

d: () => void;

}

type WTFIsThis = {

[K in keyof T]-?: T[K] extends Function ? K : never;

};

type UseIt1 = WTFIsThis;

很容易推导出UseIt1实际上就是:

type UseIt1 = {

a: never;

b: never;

c: never;

d: “d”;

};

UseIt会保留所有字段,满足条件的字段其键值为字面量类型(值为键名)

加上后面一部分:

// “d”

type UseIt2 = UseIt1[keyof UseIt1];

这个过程类似排列组合:never类型的值不会出现在联合类型中

// string | number

type WithNever = string | never | number;

所以{ [K in keyof T]: ... }[keyof T]这个写法实际上就是为了返回键名(准备的说是键名组成的联合类型)。

那么非函数类型字段也很简单了,这里就不做展示了,下面来看可选字段OptionalKeys与必选字段RequiredKeys,先来看个小例子:

type WTFAMI1 = {} extends { prop: number } ? “Y” : “N”;

type WTFAMI2 = {} extends { prop?: number } ? “Y” : “N”;

如果能绕过来,很容易就能得出来答案。如果一时没绕过去,也很简单,对于前面一个情况,prop是必须的,因此空对象{}并不能满足extends { prop: number },而对于prop为可选的情况下则可以。因此我们使用这种思路来得到可选/必选的键名。

  • {} extends Pick<T, K>,如果K是可选字段,那么就留下(OptionalKeys,如果是 RequiredKeys 就剔除)。

  • 怎么剔除?当然是用never了。

export type RequiredKeys = {

[K in keyof T]-?: {} extends Pick<T, K> ? never : K;

}[keyof T];

这里是剔除可选字段,那么 OptionalKeys 就是保留了:

export type OptionalKeys = {

[K in keyof T]-?: {} extends Pick<T, K> ? K : never;

}[keyof T];

只读字段IMmutableKeys与非只读字段MutableKeys的思路类似,即先获得:

interface MutableKeys {

readonlyKeys: never;

notReadonlyKeys: “notReadonlyKeys”;

}

然后再获得不为never的字段名即可。

这里还是要表达一下对作者的敬佩,属实巧妙啊,首先定义一个工具类型IfEqual,比较两个类型是否相同,甚至可以比较修饰前后的情况下,也就是这里只读与非只读的情况。

type Equal<X, Y, A = X, B = never> = (() => T extends X ? 1 : 2) extends <

T

() => T extends Y ? 1 : 2

? A

: B;

  • 不要被<T>() => T extends X ? 1 : 2干扰,可以理解为就是用于比较的包装,这一层包装能够区分出来只读与非只读属性。

  • 实际使用时(非只读),我们为 X 传入接口,为 Y 传入去除了只读属性-readonly的接口,为 A 传入字段名,B 这里我们需要的就是 never,因此可以不填。

实例:

export type MutableKeys = {

[P in keyof T]-?: Equal<

{ [Q in P]: T[P] },

{ -readonly [Q in P]: T[P] },

P,

never

;

}[keyof T];

几个容易绕弯子的点:

  • 泛型 Q 在这里不会实际使用,只是映射类型的字段占位。

  • X Y 同样存在着 分布式条件类型, 来依次比对字段去除 readonly 前后。

同样的有:

export type IMmutableKeys = {

[P in keyof T]-?: Equal<

{ [Q in P]: T[P] },

{ -readonly [Q in P]: T[P] },

never,

P

;

}[keyof T];

  • 这里不是对readonly修饰符操作,而是调换条件类型的判断语句。

基于值类型的 Pick 与 Omit

前面我们实现的 Pick 与 Omit 是基于键名的,假设现在我们需要按照值类型来做选取剔除呢?

其实很简单,就是T[K] extends ValueType即可:

export type PickByValueType<T, ValueType> = Pick<

T,

{ [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]

;

export type OmitByValueType<T, ValueType> = Pick<

T,

{ [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]

;

条件类型承担了太多…

工具类型一览

总结下我们上面书写的工具类型:

  • 全量修饰接口:Partial Readonly(Immutable) Mutable Required,以及对应的递归版本。

  • 裁剪接口:Pick Omit PickByValueType OmitByValueType

  • 基于 infer:ReturnType ParamType PromiseType

  • 获取指定条件字段:FunctionKeys OptionalKeys RequiredKeys …

需要注意的是,有时候单个工具类型并不能满足你的要求,你可能需要多个工具类型协作,比如用FunctionKeys+Pick得到一个接口中类型为函数的字段。

如果你之前没有关注过 TS 类型编程,那么可能需要一定时间来适应思路的转变。我的建议是,从今天开始,从现在的项目开始,从类型守卫、泛型、最基本的Partial开始,让你的代码精准而优雅

尾声

在结尾说点我个人的理解吧,我认为 TypeScript 项目实际上是需要经过组织的,而不是这一个接口那一个接口,这里一个字段那里一个类型别名,更别说明明可以使用几个工具类型轻松得到的结果却自己重新写了一遍接口。但很遗憾,要做到这一点实际上会耗费大量精力,并且对业务带来的实质提升是微乎其微的(长期业务倒是还好),毕竟页面不会因为你的类型声明严谨环环相扣就 PVUV 暴增。我目前的阶段依然停留在寻求开发的效率和质量间寻求平衡,目前的结论:多写 TS,脚本/爬虫/配置/demo,能用TS的就用TS写,写到如臂使指,你的效率就会 upu

参考资料

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

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

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

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)
img

最后

四轮技术面+一轮hr面结束,学习到了不少,面试也是一个学习检测自己的过程,面试前大概复习了 一周的时间,把以前的代码看了一下,字节跳动比较注重算法,面试前刷了下leetcode和剑指offer, 也刷了些在牛客网上的面经。大概就说这些了,写代码去了~

祝大家都能收获大厂offer~

篇幅有限,仅展示部分内容

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
img

系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!**

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-3EMwA9BK-1712769657462)]
[外链图片转存中…(img-o5cP0CS4-1712769657462)]
[外链图片转存中…(img-dH3REtSp-1712769657463)]
[外链图片转存中…(img-4B8vE4Sw-1712769657463)]
[外链图片转存中…(img-bP09SVzw-1712769657463)]
[外链图片转存中…(img-BTJslMvy-1712769657464)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

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

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)
[外链图片转存中…(img-OsYDAJf8-1712769657464)]

最后

四轮技术面+一轮hr面结束,学习到了不少,面试也是一个学习检测自己的过程,面试前大概复习了 一周的时间,把以前的代码看了一下,字节跳动比较注重算法,面试前刷了下leetcode和剑指offer, 也刷了些在牛客网上的面经。大概就说这些了,写代码去了~

祝大家都能收获大厂offer~

篇幅有限,仅展示部分内容

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
[外链图片转存中…(img-xV6r11KK-1712769657464)]

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值