TypeScript 从入门到实践

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

介绍

众所周知 JavaScript 是一门弱类型语言,在前端进入工程化后,代码仓库越来越大,JavaScript 弱类型的缺点被无限放大,使其难以胜任开发大型项目。在一个多人维护的项目中,往往不知道别人写的函数是什么意思、需要传入什么参数、返回值是什么,一个用法不小心就会导致线上出现 BUG,所以除了靠口口相传以外还要维护大量的代码注释或者接口文档来提供其他人了解。但是当我们使用 TypeScript 后,除了初期具有一定的学习成本以外,基本上可以很好的解决上述的问题。

TypeScriptJavaScript 的严格超集,这意味着任何合法的 JavaScript 代码在 TypeScript 中都是合法的。TS 的作者是 安德斯·海尔斯伯格[1],2012 年 10 月,微软发布了首个公开版本的 TypeScript,2013 年 6 月 19 日正式发布了正式版 TypeScript。根据 Roadmap[2] 我们可以知道 TS 官方每隔三个月会更新一个正式的 minor 版本,比如会在今年的 5.27 发布了 v4.7[3]。更多关于 TS 的故事可以查看 TypeScript 团队成员 orta 的文章:Understanding TypeScript's Popularity[4]

当然在 TS 彻底大火之前,同期也存在很多相似的工具来辅助开发者做好类型提示,比如 Flow[5] 、JSDoc[6] 等。

d3d0184467d81075de2ea7bf4e864b24.png

JSDoc,通过注释的方式给 add 函数添加类参数类型和返回值的类型。通过在编辑器顶部添加 @ts-check 的注释开始 VSCode 对其的检查。

127a771749d6befea2d0f4a11cbc9434.png

Flow,类似 TS 的写法添加类型注解,可以通过安装插件或者命令扫描出当前代码中有问题的地方。

根据 npm 的现在趋势,目前 Flow 的使用率比 TS 低了很多。

bd90a8e793abd624bf3747f29bc705eb.png

经过众多的开发者选择,目前来看 TS已经是完全胜出了。许多著名的开源库都采用 TS 进行编写。比如 VueJS,其中 v2.x 版本是使用的 Flow 进行的类型编写,但是 v3.x 版本已经全部迁移到了 TS。至于为什么选择 TS 替代 Flow 可以参看 尤雨溪的回答[7]

所以说 TS 逐渐的得到了社区的认可,就像 HTMLCSSJS 一样快成为一名前端开发者必备的技能。所以我们不得不去学习它、并将它灵活的运用在项目当中。

基础使用

  1. 基本类型的介绍与适用场景

  2. 交叉和联合类型

  3. 类型检查机制:推断、断言、保护和守卫

  4. 全局类型、类型引入、类型重写、类型合并(interface 和 type 的区别)

基础类型

由于 TSJS 的严格超集,所以 JS 中支持的类型在 TS 中肯定支持,所以在 JS 代码中使用什么类型的变量,在 TS 中也使用该类型。

TS 具有类型自动推断的能力,比如当我们使用 const 或者 let 声明一个变量的时,如果直接有赋值,那么就会给当前变量设置成这个赋值的类型。如下:

let str = 'hello'; // let str: string;
let num = 666;  // let num: number;
let bool = true; // let bool: boolean
let undef = undefined; // let undef: undefined
let nul = null; // let nul: null
let sym = Symbol(123); // let sym: symbol
const fn = () => 123; // const fn: () => number
let res = fn(); // let res: number
let arr = [1, 2, 'abc']; // let a: (string | number)[]
const obj = {
    a: 1,
    b: true,
}
/* const obj: {
    a: number;
    b: boolean;
}*/

在有些同学的尝试过程中,当赋值为 undefinednull 时,会自动推断成 any 。这是由于 tsconfig.json 中的配置有问题,把 compilerOptions.strictNullChecks 设置为 true 即可。目前发现团队中有部分项目都没有开启该配置,那么可能会在运行时出现意料之外的 BUG

新增的类型

当然为了代码的灵活编写 TS 还具有一些独特的类型:

0a1258b355da705ab14c5bed9dd42a13.png

类型间的关系

通过上文我们可以得出类型之间的关系:

dfb770e92f5442cad06bdb9c66c27fe7.png

以上表格是在严格模式下,即 strictNullCheckstrue 也是推荐的配置

联合与交叉

我们在实际的开发过程中变量的类型并不是单一的,比如 array.find 既可以返回数据的类型也可以返回 undefined,或者说我们写一个 mixin 的方法需要将 2 个类型合并。

交叉类型

交叉类型是使用 & 符合将多个类型合并成一个类型。如:type C = A & B 这样 C 类型 就既有 A 的类型 也有 B 的类型。常见的如 Object.assign 方法,可以将对象进行合并,所以就需要这样的方法将每个对象的类型进行合并,或者说我们在编写 React 高阶组件时,编写的过程中就可以对已有类型进行拓展。

interface Props {
  name: string;
  age: number;
}
interface WithHOCProps {
  options: {
    size: number;
  }
}

const App: React.FC<Props & WithHOCProps> = ({
  options,
}) => {
  options.size // number
}

由于是类型合并,所以可能会遇到 2 个类型不兼容的情况,所以如果遇到不兼容的类型就会推导出 never

interface Item1 {
  id: string;
  name: string;
}
interface Item2 {
  id: number;
  age: number;
}

type C = Item1 & Item2;
type Id = C['id'] // never
type Name = C['name'] // string
type Age = C['age'] // number

因为上文(类型间的关系)已经展示出了不一样的类型也存在可以相互转换的。所以 A & B的运算关系可以看成:

  • A 和 B 可以相互赋值 => 目前只有 any 可以满足这种情况

  • A 可以赋值给 B,B 不能赋值给 A => A

  • B 可以赋值给 A,A 不能赋值给 A => B

  • A 和 B 不存在可以赋值的关系 => never

type T1 = number & string; // never
type T2 = number & unknown; // number
type T3 = number & any; // any
type T4 = number & never; // never

联合类型

联合类似是使用 | 符合将多个类型联系起来,如:C = A | B 表明 C 要么等于类型 A 要么等于类型 B。主要用于当我们一个变量的类型不固定时,比如一个函数运行过程中正常的运算结果返回 A ,运算失败返回 B

const fn = (num: number): number | string => {
    if (num >=0) {
        return num;
    } else {
        return 'error';
    }
}
const res = fn(1);

当一个值是联合类型时,只可以调用联合类型的共有属性。如上面的 res 类型是 number | string ,如果不加以判断只能调用共有的 toStringvalueOf 等方法。

类型推断与保护

正常情况下类型具有自动推断的能力,比如我们声明一个变量 const num = 1,TS 会自动将变量的类型推断成 number,所以后面我们就可以对 num 变量使用 number 的一些操作方法。但是当使用联合类型的时候,TS 在编译阶段就无法得知当前的变量类型是什么,所以只可以使用共有的一些方法,所以我们需要使用类型保护的能力,比如可以通过一些判断来缩小当前变量或者断言当前变量的类型。

typeof

typeof 是判断变量类型的一个操作符,我们可以通过typeof将类型缩小变成一个受保护的类型,如:

const fn = (value: number | string): void => {
  value // value is number | string

  if (typeof value === 'number') {
    value // value is number
    value.toFixed // no error
  } else {
    value // value is string
    value.length // no error
  }
}

instanceof

typeof 类似,instanceof 也是一个判断变量类型的方式,比如一个函数可以同时接收不同的普通的对象,就会导致 typeof value === 'object' 分辨不出来。就可以使用 instanceof 来辨别对象是什么。

class User {
  say(): void {
    console.log('hello');
  }
}

class Stu {
  read(): void {
    console.log('read');
  }
}

const fn = (value: Stu | User): void => {
  value // value is Stu | User

  if (value instanceof User) {
    value // value is User
    value.say() // no error
  } else {
    value // value is Stu
    value.read // no error
  }
}

in

in 可以检查是否存在某个属性,如:

type Item1 = {
  type: 'item1';
  name: string;
  age: number;
}

type Item2 = {
  type: 'item2',
  title: string;
  description: string;
}

const fn = (value: Item1 | Item2): void => {
  if ('name' in value) {
    value.age // no error
  } else {
    value.description // no error
  }
}

字面量判断

如上面的例子,如果后期对 Item2添加name属性就容易导致这里类型判断失效,导致获取 value.age 就会存在问题。所以针对上述这种存在 type 区分的情况,可以直接使用字面量判断。

const fn = (value: Item1 | Item2): void => {
  if (value.type === 'item1') {
    value.age // no error
  } else if (value.type === 'item2') {
    value.description // no error
  }
}

is 关键字自定义

使用上面的方法在简单的场景下是十分有效,但是有时候类型的判断是复杂的,或者这样的判断是通用的,所以为了避免重复的编写我们可能需要对这个类型的保护需要提取成函数,那么就可以使用 is 来进行指定。

type Item1 = {
  type: 'item1';
  name: string;
  age: number;
}

type Item2 = {
  type: 'item2',
  title: string;
  description: string;
}

const isItem1Arr = (value: any): value is Item1[] => {
  if (!Array.isArray(value)) {
    return false;
  }
  if (value.length === 0) {
    return true;
  }
  return value.every(item => item.type === 'item1');
}

const fn = (value: Item1[] | Item2[]): void => {
  if (isItem1Arr(value)) {
    value.forEach(item => {
      item.age // no error
    });
  }
}

其中 isItem1Arr 返回值是一个 boolean 如果返回的是 true 则说明当前类型是 is 后面的。

类型断言

有了类型断言我们可以轻松的迁移一个项目,但是类型断言是有害的,因为我们主动的给这个变量向 TS 类型检查器做了背书而不是通过类型保护的方式。所以假设传入的数据是有误的,就会导致运行异常,所以我们需要谨慎的使用类型断言,除非可以 100% 的保证这里类型。

as 与 <>

有的时候 TS 的检验规则是存在缺陷的,不能完美的做好类型保护,比如下面的例子,虽然我们已经提前判断过 item.parent 肯定不为空,但是在一个闭包环境中使用,由于 TS 的缺陷,类型还是失效了(因为我们是马上运行的)。但是我们可以保证这里的类型肯定是不会为 null 的,所以我们就可以断言它的类型。

interface Item {
  parent: Item | null;
}

const fn = (item: Item) => {
  if (!item.parent) {
    return;
  }
  const _fn = () => {
    item.parent // Item | null
    const parent1 = item.parent as Item;
    const parent2 = <Item>item.parent;
  }
  _fn();
}

第二种情况是,这个值是在运行过程中产生。所以我们没有办法在定义变量的时候进行初始化,这个时候就需要使用类型断言了。但是这种方式存在弊端,假设后面 User 新增了一个属性,就会导致返回的数据有缺省。所以不是很推荐这种方式,而是可以在初始化的时候设置成空值/默认值,这样当新增一个属性后,就会主动报错,提醒我们需要处理额外的属性。

interface User {
  type: 'student';
  name: string;
}

const createUser = (name: string): User => {
  // const result = {
  //   type: 'student'
  // } as User;
  const result = <User>{
    type: 'student'
  };

  if (name) {
    result.name = parseName(name);
  }

  return result;
}

! 非空断言

顾名思义,主要是排除变量中 nullundefined 的类型。比如上面提到的 item.parent 我们可以很清楚的知道他不是一个 null 的,就可以使用这个简单的方式。

interface Item {
  parent: Item | null;
}

const fn = (item: Item) => {
  if (!item.parent) {
    return;
  }
  const _fn = () => {
    const p1 = item.parent.parent; // error: (item.parent)对象可能为 null
    const p2 = (<Item>item.parent).parent // ok,item.parent 整体断言
    const p3 =item.parent!.parent; // ok,item.parent 非空,则排除 null
  }
}

双重断言

毫无根据的断言是危险的,所以进行类型断言时,TS 会提供额外的安全性,并不是每个变量间都可以断言的,比如 'licy' as number 将一个字符串转换成 number 肯定就是不行的。

如果 AB 直接存在赋值关系,即 AB 的子类型,或者 BA 的子类型就可以直接使用断言。如果不存在时,可以找一个中间的类型来做桥梁,通过上面【类型间的关系】可以得出 anyunknownnever 三个类型是最少都满足上面 2 个规律之一。

const n1 = 'licy' as number; // error: string 与 number 不能充分重叠,转换是错误的
const n2 = 'aa' as any as number;
const n3 = 'aa' as unknown as number; // 推荐
const n4 = 'aa' as never as number;

因为断言是具有危害性的,所以双重断言也是具有危害性的。我们需要尽量的少用。同时双重断言的使用场景很少很少,笔者只在一次跨 npm 包调用的时候,由于底层版本不一致,使用过一次。

全局类型

通常情况下,定义的类型需要使用 export 进行导出,在使用的地方再使用 import 导入。但是有时候在同一个项目中会有一些通用的类型或者类型方法,每次都进行导入是很繁琐的。甚至需要在 window 挂载一些新的变量,所以我们需要了解全局类型的概念。声明全局类型的方式有 2 种:

  1. 在一个 `.d.ts` 文件中写变量类型,同时不要有 `export` 和 `import` 等导入导出语法。
// global.d.ts
type AnyFunction = (...args: any[]) => any;

值得注意的是,你需要在 tsconfig.jsoninclude 选项中包含该文件。另外需要注意的一点是,如果你是一个 npm 包的类型中,如果引入 npm 包的没有引入你定义的全局类型,则会变成any

  1. 使用 `declare` 定义,比如需要给 `window` 新增类型,给某个包或者某一类文件添加类型说明等。
// global.d.ts
declare module 'react' {
  export const licy: string;
}

declare module 'npm-package' {
  export const props: { name: 'licy'; age: number }
  const App: React.FC<typeof props>;
  export default App;
}

declare module '*.svg' {
  const content: {
    id: string;
  }
  export default content;
}


// app.ts
import React from 'react';
import svg from './log.svg';
import { props } from 'npm-package';

React.licy // string
svg.id // string
props.name // 'licy'

当然很多时候,我们的类型还会引入一些已有类型进行组装,所以就会破坏掉默认 .d.ts 是全局类型的约束,所以需要主动的导出。

// global.d.ts
import { ValueOf } from "./type";

declare namespace CommonNS {
  interface Props {
    name: 'licy';
    age: 24
  }
  type Value = ValueOf<Props>;
}

// 缺一不可,否则类型使用会加前缀
// 将 CommonNS 作为全局类型,类似 UMD
export as namespace CommonNS;
// 将导出命名修改,否则就会使用 CommonNS.CommonNS.XXX 才可以获取
export = CommonNS;


// main.ts
const value: CommonNS.Value = 'licy';

在全局选项这里需要注意 skipLibCheck 的配置,如果该选项配置为 true 则会跳过库文件的类型检查,比如 node_moduels 中其他库的类型检查和当前项目的 .d.ts 检查。所以会导致在编写 .d.ts 文件的时候不一察觉错误,但是有不能保证引入的 npm 库的类型文件都是正确的。所以可以在 tsconfig.json 将该选项设置为 false 然后在编译阶段再将该选项设置为 true

高级用法

函数重载

函数重载是静态类型语言当中很重要的一个能力。很多时候编写的函数可能会兼容多种参数类型,可能会根据传入的参数会返回不同的数据。比如:

const data = { name: 'licy' };
const getData = (stringify: boolean = false): string | object => {
  if (stringify === true) {
    return JSON.stringify(data);
  } else {
    return data;
  }
}

const res1 = getData(); // string | object
const res2 = getData(true); // string | object

在上述的例子中调用 getData 方法得到一个联合了联合类型,还需要进行判断将类型缩小或者使用 as 进行指定。但是如果作为方法的编写者,当确定传入的参数后就可以很准确的得到返回值的类型,而不是得到这种模棱两可的情况。所以借助函数重载进行改造:

const data = { name: 'licy' };
function getData(stringify: true): string
function getData(stringify?: false): object
function getData(stringify: boolean = false): unknown {
  if (stringify === true) {
    return JSON.stringify(data);
  } else {
    return data;
  }
}

const res1 = getData(); // object
const res2 = getData(true); // string

函数重载的使用方法很简单,就是在需要使用函数重载的地方,多声明几个函数的类型。然后在最后一个函数中进行实现,特别要注意的是,最后实现函数中的类型一定要与上面的类型兼容。

值得注意的是由于 TS 是在编译后会将类型抹去生成 JS 代码,而 JS 是没有函数重载这样的能力,所以说这里的函数重载只是类型的重载,方便做类型的提示,实际上还是要在实现函数中进行传入参数的判别,然后返回不同的结果。

泛型

泛型是 TS 一个比较高级的用法,在日常的开发中也是使用比较多的。当你的函数,接口或者类需要支持多种类型的时候就可以使用泛型,比如上面函数重载的例子,也可以使用泛型进行改造。

// 泛型函数
const data = { name: 'licy' };
function getData<T extends boolean = false, R = T extends true ? string : object>(stringify?: T): R {
  if (stringify === true) {
    return JSON.stringify(data) as unknown as R;
  } else {
    return data as unknown as R;
  }
}

const res1 = getData(); // object
const res2 = getData(true); // string

// 泛型类型
type ValueOf<T> = T[keyof T];

interface User {
  name: 'licy';
}

type A =  keyof User; // 'name'
type B = ValueOf<User>; // 'licy'

亦或者需要对传入进来的参数进行保存时,比如编写 React 中的 HOC

type AnyObject = Record<string, any>;
type ExtraProps = {
  name: 'licy'
};
const withItem = <
  T extends AnyObject
>(Comp: React.FC<T>): React.FC<T & ExtraProps> => {
  const NewComp: React.FC<T & ExtraProps> = (props) => {
    props.name // 'licy'
    return <Comp {...props} />
  }
  NewComp.displayName = 'with-item';
  return NewComp;
}

const Demo: React.FC<{ age: number }> = () => null;

const NewDemo = withItem(Demo);

const res = (
  <>
    <Demo age={24} /> no error
    <NewDemo age={24} name="licy" /> no error
    <NewDemo age={24} /> error: 缺少属性 "name"
  </>
)

内置的高级函数

为了方便类型编写,TS 官方内置了许多通用的高级类型方法,这些方法可以帮助我们完成程序中大部分的类型转换。但是如果我们掌握了这些类型方法的实现方式,也可以很轻松的写出符合业务逻辑规范的高级方法。所有的内置方法可以参考:utility-types[8] 本文只介绍一些典型的。

fe63efb78e6ceeebc6da6740a5dbea0a.png

通过上面 TS 内部实现的高级类型可以发现,extendsinfer 是特别重要的。extends 可以实现类似三元表达式的判断,判断传入的泛型是什么类型的,然后返回定义好的类型。infer 可以在判断是什么类型后,可以提取其中的类型并在子句中使用。和我们正常写代码一样,说明我们可以多个 extendsinfer 进行嵌套使用,这样就可以把一个传入的泛型进行一次次分解。

协变与逆变

在了解协变与逆变之前我们需要知道一个概念——子类型。我们前面提到过 string 可以赋值给 unknown 那么就可以理解为 stringunknown 的子类型。正常情况下这个关系即子类型可以赋值给父类型是不会改变的我们称之为协变,但是在某种情况下两者会出现颠倒我们称这种关系为逆变。如:

interface Animal {
  name: string;
}

interface Cat extends Animal {
  catBark: string;
}

interface OrangeCat extends Cat {
  color: 'orange'
}

// ts 中不一定要使用继承关系,只要是 A 的类型在 B 中全部都有,且 B 比 A 还要多一些类型
// 类似集合 A 属于 B 一样,这样就可以将 B 叫做 A 的子类型。

// 以上从属关系
// OrangeCat 是 Cat 的子类型
// Cat 是 Animal 的子类型
// 同理 OrangeCat 也是 Animal 的子类型

const cat: Cat = {
  name: '猫猫',
  catBark: '喵~~'
}
const animal: Animal = cat; // no error

假设我有类型 type FnCat = (value: Cat) => Cat; 请问下面四个谁是它的子类型,即以下那个类型可以赋值给它。

type FnAnimal = (value: Animal) => Animal;
type FnOrangeCat = (value: OrangeCat) => OrangeCat;
type FnAnimalOrangeCat = (value: Animal) => OrangeCat;
type FnOrangeCatAnima = (value: OrangeCat) => Animal;

type RES1 = FnAnimal extends FnCat ? true : false; // false
type RES2 = FnOrangeCat extends FnCat ? true : false; // false
type RES3 = FnAnimalOrangeCat extends FnCat ? true : false; // true
type RES4 = FnOrangeCatAnima extends FnCat ? true : false; // false

为什么 RES3 是可以的呐?

返回值:假设使用了 FnCat 返回值的 cat.catBark 属性,如果返回值是 Animal 则不会有这个属性,会导致调用出错。估计返回值只能是 OrangeCat

参数:假设传入的函数中使用了 orangeCat.color 但是,对外的类型参数还是 Cat 没有 color 属性,就会导致该函数运行时内部报错。

故可以得出结论:返回值是协变,入参是逆变。

注意如果 tsconfig.json 中的 strictFunctionTypesfalse 则上述的 RES2 也是 true ,这就表明当前函数是支持双向协变的。当然 TS 默认是关闭此选项的,主要是为了方便 JS 代码快速迁移到 TS 中,详情可以见 why-are-function-parameters-bivariant[9] ,当然如果是一个新项目,建议打开 strictFunctionTypes 选项。

允许双向协变是有风险的,可能会在运行时报错。比如在 ESLint 中有 method-signature-style[10] 规则,简单的来说该规则默认是使用 property 来声明方法,比如:

interface T1<T> {
  wrapFn: (value: T) => void;
}

interface T2<T> {
  wrapFn(value: T): void; // eslint error, 声明的方式是 method 形式
}

假设我们忽略 eslint 警告,强制 T2 的方法进行声明就会潜在的双向协变的风险,如下列代码:

declare let animalT1: T1<Animal>;
declare let catT1: T1<Cat>

animalT1 = catT1; // error, Animal 不能分配给 Cat
catT1 = animalT1; // no error

declare let animalT2: T2<Animal>;
declare let catT2: T2<Cat>

animalT2 = catT2; // no error
catT2 = animalT2; // no error

真实案例

联合类型转交叉类型

题目描述

type Value = { a: string } | { b: number }
type Res = UnionToIntersection<Value> // type Res= {  a: string } & { b: number };

思路

  1. [Distributive Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types "Distributive Conditional Types") :当条件类型作用于泛型类型时,它们在给定联合类型时变得可分配。
// extend 中的分配
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>; // string[] | number[]
  1. [逆变的特性:在逆变位置时推断出交叉类型](<https://github.com/Microsoft/TypeScript/pull/21496#:~:text=Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:> "逆变的特性:在逆变位置时推断出交叉类型")
// 逆变推断出交叉
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

结合二者

type ToUnionFunction<T> = T extends unknown ? (x: T) => void : never;
type UnionToIntersection<T> = ToUnionFunction<T> extends (x: infer R) => unknown
        ? R
        : never

测试

type Res = UnionToIntersection<Value> // type Res= {  a: string } & { b: number };

增强版 Omit

题目描述

假如我们要轻微的修改某个组件亦或者是拓展组件的某个属性,比如下面代码中的 size ,想给他增加一个 default 的配置项,但是其他的 props 都不会改变。首先肯定不想进行复制粘贴,其次也为了后续内部的组件 props 变更后,外层的逻逻辑不改。

第一印象就是继承然后修改 size 的值,但是很遗憾因为新的类型与已有的类型不兼容,所以不能覆盖成功。所以谋生了第二种思路,想把 size 给排除出去,然后重写。但是也不行,因为拓展的组件为了方便添加了 [key: string]: unknown,导致 omit 有问题。

interface Props {
  title: string;
  size: 'small' | 'large';
  [key: string]: unknown;
}

interface NewProps1 extends Props {
  size: 'small' | 'large' | 'default'; // error: 不能将 default 分配给 'small' 和 'large'
}

interface NewProps2 extends Omit<Props, 'size'> {
  size: 'small' | 'large' | 'default';
}

const a: NewProps2 = {
  title: 123, // no error, 但是我们知道 title 类型丢失了
  size: 'default',
}

思路

因为是添加了 [key: string]: unknown 后才导致 omit 失效的,根据上面 omit 的实现我们可以知道实际上是 keyof 方法不能处理 [key: string] 这样的索引签名,根据常识对象索引签名的类型只支持 string、number、symbol 和字面量。所以只需要保留字面量的 key 就行了。

/**
 * 我们已知
 *  1. K 只会为 string | number | symbol | 字面量
 *  2. string extends K 时,只有 K 为 string 时才是 true, 同理这里可以检查出 number 和 symbol 然后 as 为 never.
 */
type KnownKeys<T> = keyof {
  [K in keyof T as (
    string extends K
      ? never
      : number extends K
        ? never
        : symbol extends K
          ? never
          : K)
  ]: never;
};

/**
 * 因为 Pick 的第二个参数需要 K extends keyof T
 * 所以这里需要判断 KnownKeys<T> extends keyof T,
 */
export type ObtainKeyOmit<T, K extends KnownKeys<T>> = KnownKeys<T> extends keyof T ? Pick<T, Exclude<KnownKeys<T>, K>> : never;

测试

interface NewProps3 extends ObtainKeyOmit<Props, 'size'> {
  size: 'small' | 'large' | 'default';
  [key: string]: unknown; // 因为 ObtainKeyOmit 去掉了,所以需要加回来
}

const a: NewProps3 = {
  title: 123, // error, number 不能赋值给 string
  size: 'default',
  name: 'licy',
}

下划线转驼峰

题目描述

根据目前大多数的规范来说,服务端开发的同学大多数是使用下划线命名,而前端的同学是用小驼峰命名。所以大部分同学会在 axios 请求数据回来的 hooks 中使用 camelcase-keys 将数据中的下划线转换成小驼峰。但是大多数接口的类似是使用 thrift 进行转换的,所以生成的类型文件也是下划线构造的。所以可以完成一个方法将下划线转换成小驼峰。

// thrift 的构造,key 为下划线,其中值有可能是数组或者对象的嵌套
interface User {
  user_id: string;
  name: string;
  user_status?: number;
  avatar_url: {
    default_url: string;
    small_url?: string;
    large_url?: string;
  };
  colleague: {
    user_name: string;
    user_id: string;
    user_status?: number;
  }[];
};

思路

  1. 首先需要完成下划线字符串转驼峰的方法 Under2camel

    1. 使用 T extends `${infer F}_${infer L}` 的方式可以获取 _ 前后的值

    2. 使用 Capitalize 可以将字符串的首字母大写

    3. 注意兼容 _xx 开头的数据

  2. `Under2camelDeep` 的方法其实只是根据值的类型添加的递归处理
    1. key 进行转换,如果是字符串就使用 Under2camel 方法,如果是 numbersymbol 就跳过。

    2. 使用 T[K] extends (infer O)[] 判断是数组,并且还可以提取其中的值

// 只做字符串的转换
type Under2camel<T extends string> = T extends `${infer F}_${infer L}`
  ? F extends ''
    ? Under2camel<L>
    :`${F}${Under2camel<Capitalize<L>>}`
  : T;
type AAA = Under2camel<'_user_id_ddd_aaa_'>; // userIdDddAaa

// 递归遍历
type Under2camelDeep<T> = T extends (infer U)[]
  ? Under2camelDeep<U>[]
  : {
    [K in keyof T as (K extends string ? Under2camel<K> : never)]: T[K] extends Record<string, unknown>
      ? Under2camelDeep<T[K]>
        : T[K] extends (infer O)[]
          ? Under2camelDeep<O>[]
          : T[K]
  }

测试

const data: Under2camelDeep<User> = {
  userId: 'id001',
  name: 'licy',
  userStatus: 0,
  avatarUrl: {
    smallUrl: 'https://xxx',
    defaultUrl: 'https://xxx'
  },
  colleague: [
    {
      userName: 'zhangsan',
      userId: 'id002',
      userStatus: 0,
    }
  ]
};

课后作业

编写一个 Under2camelDeep 方法的相反方法,Camel2underDeep 可以将小驼峰命名转换成 _ 连接。

加强版 Pick

题目描述

很多时候我们会使用 lodashpick 方法进行深度的选取,如 _.pick(o, ['a', 'b.c', 'b.e[0].a'])。所以也希望可以提供一个 DeepPick 的方法可以进行深度的选取。

interface User {
  name: string;
  address: {
    country?: string;
    city: string;
  };
  spend: {
    price: number;
    description?: string;
  }[];
}

type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;

思路

  1. 前文提到过,联合类型具有分配的性质,可以想象成一个数组,每一次只会有一个值代入表达式计算,最后的结果也是一个联合类型。

  2. 使用 infer 的方式去提取字符串

  3. 由于 [0].. 有共同的部分,需要先判断 [0].

  4. 递归即可

  5. 得到的数据是 {name: string} | { address: { city: BJ } } 的形式,并不是一个交叉的类型。

  6. 使用前文提到的 `UnionToIntersection` 方法将联合类型转换成交叉类型。
type _DeepPick<T, U> = U extends `${infer F}[0].${infer Rest}`
  ? F extends keyof T
    ? T[F] extends (infer O)[]
      ? { [P in F]: DeepPick<O, Rest>[] }
      : never
    : never
  : U extends `${infer F}.${infer Rest}`
    ? F extends keyof T
      ? { [P in F]: DeepPick<T[F], Rest> }
      : never
    : U extends keyof T
      ? { [P in U]: T[U] }
      : never;

type DeepPick<T, U> = UnionToIntersection<_DeepPick<T, U>>;

测试

type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;
// {
//   name: string;
//   address: {
//     city: string;
//   };
//   spend: {
//     price: number;
//   }[]
// }

const a: NewUser = {
  name: 'licy',
  address: {
    city: 'BJ',
  },
  spend: [
    {
      price: 100,
    },
  ],
};

课后作业

  1. 如果加入 `address.country` 后,由于该项是一个可选项,所以可以不写,但是目前解析成了 `string | undefinde` 所以不写会进行报错,如何修复。
type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;
// {
//   name: string;
//   address: {
//     country: string | undefined;
//     city: string;
//   };
//   spend: {
//     price: number;
//   }[]
// }

const a: NewUser = {
  name: 'licy',
  address: { // 报错,缺少 country 类型
    city: 'BJ',
  },
  spend: [
    {
      price: 100,
    },
  ],
};
  1. 修改 DeepPick<T, U> 中的 U 可以让输入的时候和 Pick 方法一样,有提示。

Object.assign 类型提示

题目描述

Object.assign 是一个常用 API 方法,但是目前来说这个方法自动推断的类型是有问题的,因为他是采用 type1 & type2 的方式,所以如果 2 个类型没有共有属性,就会得到 never

type O1 = {
  id: string;
  value: string;
  age?: number;
  extra?: {
    a: number;
    type: 'a';
  };
};

type O2 = {
  name: string;
  value: number;
  city?: string;
  extra?: {
    type: 'b';
  };
};

type O3 = {
  city: string[];
};

const o1: O1 = {
  id: 'id1',
  value: 'abc',
  age: 24,
  extra: {
    a: 1,
    type: 'a',
  },
};

const o2: O2 = {
  name: 'licy',
  value: 0,
  city: 'BJ',
  extra: {
    type: 'b',
  },
};

const o3: O3 = {
  city: ['bj'],
};

const res = Object.assign({}, o1, o2, o3);
// 类型丢失了
res.value // never
res.extra // never
res.city // string & string[]

思路

因为 & 是取交集,而这里 assign 的能力是覆盖所以不能直接 &。应该判断如果后面的类型中有,则前面的类型就不需要有了。所以在遍历 T1 时,如果该 key 存在于 T2 中,则不应该有此项。

type Merge<T1 extends Record<string, unknown>, T2 extends Record<string, unknown>> = {
  [K in keyof T1 as K extends keyof T2 ? never : K]: T1[K];
} & {
  [K in keyof T2]: T2[K];
};

type Assign<T extends Record<string, unknown>, U> = U extends [infer F, ...infer Rest]
  ? F extends Record<string, unknown>
    ? Assign<Merge<T, F>, Rest>
    : Assign<T, Rest>
  : T;

测试

const res: Assign<O1, [O2, O3]> = Object.assign({}, o1, o2, o3);
// 类型推断正确
res.value // number
res.extra // { type: 'b' }
res.city // string[]

课后作业

  1. 目前这个方法不是完美的,存在一定的缺陷,假设 `O3` 的类型中有未必填的 `key` 值,然后就会导致类型推断出现问题。如:
type O3 = {
  id?: number;
  city: string[];
};

const res: Assign<O1, [O2, O3]> = Object.assign({}, o1, o2, o3);
res.id // id?: number | undefined ,有问题 因为 O1 是肯定有 id,所以推断出了问题

如何完善 Merge 方法,使得上面的 res.id 自动推断为 string | number

  1. 如果是深度的 assgin 如何实现?比如我期望上文中的 res.extra 推断为 { a: number; type:'b' }

周边工具

推荐工具

  1. 在线代码练习:TS 代码演练场[11]

  2. 本地练习:VSCode 编辑器

    1. 默认情况下是使用 VSCode 的 TS 版本进行,所以可能造成编写时的提示和编译版本不一致。可以点击图中的 {} ,然后选择 TS 版本。也可以通过 command+shift+p 调出命令界面,输入 typescript 进行选择。

d82616da181a58b352272a4b4a2be197.png 323ed86328d15ea33ad427f08e0d9374.png
  1. 很多时候我们在代码中编写工具函数,然后等着页面刷新是很麻烦的,我们可以在一个测试 TS 中间中进行编写,编写完成后直接把代码复制过去。直接运行 TS 的工具 ts-node[12],可以监听变化热更新 ts-node-dev[13]。当然也可以用目前最新的工具 bun[14]

6c235e64f6afeb50a07f6bd49fc1e62c.png2de89d4e22c2a90d95a8158f59cb0798.png

  1. 很多常用的 TS 类型工具库,可以看成和 lodash 类似,如果有不知道如何写的可以参考。TS 类型工具库[15]

  2. Lint 检查工具,以前有专门的 TS Lint 但是由于 TS LintES Lint 过程高度的相似,所以目前 TSLint 被并入了 ES lintTS Lint 也被官方标记为了放弃维护。所以可以安装:\@typescript-eslint/eslint-plugin[16] 和 \@typescript-eslint/parser[17],使用 ESLint 进行代码风格的检测。

  3. 类型覆盖检查工具,可以使用 type-coverage[18] 进行项目中类型覆盖度的检测。特别适合从一个 JS 项目迁移到 TS 项目的过程中,得到阶段性的数据,当然也可以作为一个 MR 的准入标准。比如下面就是目前低代码三个核心包的类型覆盖率,还是有一点提升空间的。

50697c7c66d5196920ab5d440cdf3015.png

推荐学习资料

TypeScript 官方使用手册[19]

深入理解 TypeScript[20]

强烈推荐】 type-challenges[21]

类型体操天花板是怎样炼成的 - Web Infra 团队定期技术分享[22]

用 TypeScript 类型运算实现一个中国象棋程序[23]

TypeScript 类型体操天花板,用类型运算写一个 Lisp 解释器[24]

\[⭐ 全技巧解析史诗典藏 ⭐\]用 TypeScript 类型写一个 Lisp 解释器 Pro (尾递归优化版)[25]

Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章2. 订阅官方博客 www.inode.club 让我们一起成长

点赞和在看就是最大的支持❤️
参考资料

[1]

安德斯·海尔斯伯格: https://baike.baidu.com/item/%E5%AE%89%E5%BE%B7%E6%96%AF%C2%B7%E6%B5%B7%E5%B0%94%E6%96%AF%E4%BC%AF%E6%A0%BC

[2]

Roadmap: https://github.com/microsoft/TypeScript/wiki/Roadmap

[3]

v4.7: https://github.com/microsoft/TypeScript/issues/48027

[4]

Understanding TypeScript's Popularity: https://orta.io/notes/js/why-typescript

[5]

Flow: https://flow.org/

[6]

JSDoc: https://jsdoc.app/

[7]

尤雨溪的回答: https://www.zhihu.com/question/46397274/answer/101193678

[8]

utility-types: https://www.typescriptlang.org/docs/handbook/utility-types.html

[9]

why-are-function-parameters-bivariant: https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-function-parameters-bivariant

[10]

method-signature-style: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/method-signature-style.md

[11]

TS 代码演练场: https://www.typescriptlang.org/zh/play

[12]

ts-node: https://www.npmjs.com/package/ts-node

[13]

ts-node-dev: https://www.npmjs.com/package/ts-node-dev

[14]

bun: https://bun.sh/

[15]

TS 类型工具库: https://github.com/millsp/ts-toolbelt

[16]

@typescript-eslint/eslint-plugin: https://www.npmjs.com/package/@typescript-eslint/eslint-plugin

[17]

@typescript-eslint/parser: https://www.npmjs.com/package/@typescript-eslint/parser

[18]

type-coverage: https://www.npmjs.com/package/type-coverage

[19]

TypeScript 官方使用手册: https://www.typescriptlang.org/docs/handbook/

[20]

深入理解 TypeScript: https://jkchao.github.io/typescript-book-chinese/

[21]

type-challenges: https://github.com/type-challenges/type-challenges

[22]

类型体操天花板是怎样炼成的 - Web Infra 团队定期技术分享: https://bytedance.feishu.cn/minutes/obcnbl4cae2792wz7vw78lom

[23]

用 TypeScript 类型运算实现一个中国象棋程序: https://zhuanlan.zhihu.com/p/426966480

[24]

TypeScript 类型体操天花板,用类型运算写一个 Lisp 解释器: https://zhuanlan.zhihu.com/p/427309936

[25]

[⭐全技巧解析史诗典藏⭐]用 TypeScript 类型写一个 Lisp 解释器 Pro (尾递归优化版): https://bytedance.feishu.cn/docs/doccnf74joJlJKkfOVNjsCyy6Gb

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值