Typescript Advance

进阶技巧的收集,适合有一定 TS 代码经验

参考来源:

确保两个数组长度相同

// 当两个数组元素类型相同时,可以直接使用可变元组类型
declare function check<T extends unknown[]>
  (a: [...T], b: [...T]): void;
 
check([1], []);  // ERROR
check([], [1]);  // ERROR
check([1], [1]); // OK
 
// 当两个数组元素类型不同时,可以通过对返回值类型进行检查
declare function check2<T extends unknown[], U extends unknown[]>
  (a: [...T], b: [...U]): T['length'] extends U['length'] ? true : false;
 
check2([1], []) satisfies true;      // ERROR
check2([], [1]) satisfies true;      // ERROR
check2([1], ['foo']) satisfies true; // OK
 
// 对返回值类型进行检验不是解决问题的唯一的办法,我们还有:
type DoCheck<T extends unknown[], U extends unknown[]> =
  T['length'] extends U['length'] ? unknown : never;
 
// 考虑这样一个事实:unknown 是 top type(全集),所以其它类型对它取交都等于原类型
// 而 never 是 bottom type(空集),其它类型对它取交都等于 never
declare function check3<T extends unknown[], U extends unknown[]>
  (a: [...T] & DoCheck<T, U>, b: [...U] & DoCheck<T, U>): void;
 
check3([1], []);      // ERROR
check3([], [1]);      // ERROR
check3([1], ['foo']); // OK

匹配元组类型

使用 [...T] 可以提示 TypeScript 将 T 推导为元组类型而不是数组类型:

declare function test<T extends unknown[]>(_: [...T]): T;
 
const _3 = test(["seele", 114, false]);
//    ^? const _3: [string, number, boolean]

定制错误信息

declare const ERROR_SYMBOL: unique symbol;
type MyTypeError<T extends string> = { [ERROR_SYMBOL]: T };
 
type DoCheck<T> = "foo" extends keyof T
  ? unknown
  : MyTypeError<"对象没有包含必须的类型 foo 哦">;
 
// ...
 
function check<T extends Record<string, unknown>>(input: T & DoCheck<T>) {
  return input;
}
 
check({}); // ERROR
// Property '[ERROR_SYMBOL]' is missing in type '{}'
// but required in type 'MyTypeError<"对象没有包含必须的类型 foo 哦">'
check({ foo: true }); // OK
check({ foo: true, bar: false });

稍微看了一下,有人已经提了一个 PR (opens in a new tab),实现方式是利用已有的 throw 关键字替代 never

playground (opens in a new tab)

作者甚至持续维护了两年。可惜

检查一个类型仅含 key foo

type DoCheck<T> = [keyof T] extends ["foo"]
  ? ["foo"] extends [keyof T]
    ? unknown
    : never
  : never;
 
function check<T extends Record<string, unknown>>(input: T & DoCheck<T>) {
  return input;
}
 
check({}); // ERROR
check({ foo: true }); // OK
check({ foo: true, bar: false }); // ERROR

非空数组

declare const TYPE_TAG: unique symbol; // 2.7+
type NonEmptyArray<T> = readonly [T, ...T[]] & { [TYPE_TAG]: never };
 
// 给用户提供一个函数来进行检查和类型转换
function asNonEmptyArray<T>(array: readonly T[]): NonEmptyArray<T> {
  if (!array.length) {
    throw new Error(...);
  }
 
  return array as any;
}
 
declare function last<T>(array: NonEmptyArray<T>): T;
 
// 这样,用户在调用 last 函数之前就必须先确保自己的函数非空
const nonEmptyArray = asNonEmptyArray(myArray);
last(nonEmptyArray);
 
// 另一个选择:
// asNonEmptyArray<T>(array: readonly T[]): NonEmptyArray<T> | null
// 当输入的数组为空时返回 null,使用户强制检查这个 null 值的存在

为什么使用 [T, ...T[]] 而不是 T[], tsconfig 中的 noUncheckedIndexedAccess 选项:

// 当 noUncheckedIndexedAccess 打开时
declare const arr1: number[];
const _1 = arr1[0];
//    ^? const _1: number | undefined
 
declare const arr2: [number, ...number[]];
const _2 = arr2[0];
//    ^? const _2: number
 
// 由于我们的「非空数组」已经暗含了数组的第一位不可能为空
// 所以可以使用可变元组的方式让 TypeScript 相信数组的第一位不可能为 undefined

工具类型 Prettify

简而言之就是拍平对象结构

export type Prettify<T> = {
  [K in keyof T]: Prettify<T[K]>;
} & {};

这里的核心是 & {},哪里不能推导出结构,就在那一层加上

Immediately Indexed Mapped Type(IIMT) (opens in a new tab)

也是一个比较常用的技巧,直接映射出对象类型

type CSSUnits = "px" | "em" | "rem" | "vw" | "vh";
 
/**
 * | { length: number; unit: 'px'; }
 * | { length: number; unit: 'em'; }
 * | { length: number; unit: 'rem'; }
 * | { length: number; unit: 'vw'; }
 * | { length: number; unit: 'vh'; }
 */
export type CSSLength = {
  [U in CSSUnits]: {
    length: number;
    unit: U;
  };
}[CSSUnits];
type Event =
  | {
      type: "click";
      x: number;
      y: number;
    }
  | {
      type: "hover";
      element: HTMLElement;
    };
 
// 对某个含有 type 属性的对象类型,将它的 type 属性加上一个字符串前缀
// 同时,其它属性保持不变
type PrefixType<E extends { type: string }> = {
  type: `PREFIX_${E["type"]}`;
} & Omit<E, "type">;
 
/**
 * | { type: 'PREFIX_click'; x: number; y: number; }
 * | { type: 'PREFIX_hover'; element: HTMLElement; }
 */
type Example = {
//               👇 使用了「映射类型中键的重映射」
  [E in Event as E["type"]]: PrefixType<E>;
}[Event["type"]];

Exhaustive Guard

也是之前提到过 switch case 中检测所有 case 都处理的技巧

function exhaustiveGuard(value: never): never {
  throw new Error(`Exhaustive guard failed with ${value}`);
}

new:如果你不在乎运行时的兜底,可以使用 satisfies 4.9+ 来做到相同的事情。

enum MyType {
  Foo,
  Bar,
  EEE
}
declare const getSomeValue: () => MyType;
const val = getSomeValue();
switch (val) {
  case MyType.Foo:
    // 此时 someValue 的类型为 MyType.Foo
    break;
  case MyType.Bar:
    // 此时 someValue 的类型为 MyType.Bar
    break;
  default:
    val satisfies never; // 确实会报错
}

控制流中的类型具化 Discriminated Union Types

可以通过单独设置一个公共属性,比如 type 来进行类型具化

如果三个 interface 都含有不同的属性,那么我们通过 in 关键字就能够让 TypeScript 利用类型具化的机制进行区分。但是,实际情况中我们更多地会遇到一些部分含有相同属性的类型

function myFunction(value: Apple | Banana | Watermelon) {
  // 如何类型安全地区分 value 的不同的类型?
}

阻止联合类型的 Subtype Reduction

比较实用的小技巧,能提供更好的代码补全

比如如下遇到的场景,支持接受指定字符串和 string 全集

TypeScript 会将联合类型中的 'foo' 约去,因为这个字面量类型是它的子类型,而且它的值可以覆盖 'foo'。这个过程被称为 subtype reduction。

declare function foo(input: "a" | "b" | string): void;
foo(""); // 什么提示都没有
 
declare function foo2(input: "aaa" | "bbb" | (string & {})): void;
foo2("bbb"); // 可以提示 'bbb'

阻止 Type Alias Preservation

4.2 版本引入的 Smarter Type Alias Preservation 特性 (opens in a new tab),TypeScript 不会展开这些联合类型。很难看出这个类型具体是哪些类型的联合。

type Foo = 1 | 2 | 3;
type Bar = Foo | 4 | 5;
//   ^? type Bar = Foo | 4 | 5
type Bar2 = (Foo & {}) | 4 | 5;
//   ^? type Bar2 = 1 | 2 | 3 | 4 | 5

unique symbol

declare const TYPE_TAG: unique symbol; // 2.7+
// 通过 & 并入一个特殊属性来定义名义类型(一些人将这个过程称为 Tagging)
type PositiveValue = number & { [TYPE_TAG]: "_" };

关键字:unique2.7 加入的 (opens in a new tab)

unique symbolsymbol 的一个子类型

上面的例子使用 unique symbol 而不是字符串作为属性名不是必要的,不过推荐使用这种方法,因为 Lanuage Service 提供的自动补全不会将这个属性考虑在内,避免对用户造成不必要的干扰,同时也能避免用户无意中访问这个「假的属性」造成运行时错误,因为这个属性只是我们在类型里附加上去的。

附:一些 well-unknown symbols (opens in a new tab)

添加元信息

playground (opens in a new tab)

declare const TYPE_TAG: unique symbol; // 2.7+
type StringInjectToken<T> = string & { [TYPE_TAG]: T };
 
// 依赖注入的函数
declare function inject<T>(token: StringInjectToken<T>): T | null;
 
// 我们的用户服务和对应的注入 Token
declare class UserService {}
const USER_SERVICE = "userService" as StringInjectToken<UserService>;
 
// 用户可以通过使用这个 token 获得类型提示
const userService = inject(USER_SERVICE);

这里用了 unique symbol 取构造一个类型的元信息,实际上不作为一个属性存在对象上,但是可以通过类型获取到,仅供类型检查,妙啊

类型 {} 到底是什么?

从 TypeScript 4.8 开始 (opens in a new tab){} 等价于「任何非 nullundefined 的类型」,并且有:type NonNullable<T> = T extends null | undefined ? never : T 等价于 T & {}。换句话说,stringnumberboolean 等常见类型也能够被赋给 {}

因此,{} 框定的值的范围实际上要大于「任何对象类型」。如果确实想表示对象类型,Record<string, unknown> 一般会是更好的选择。

string & K 是什么意思?

对象的属性名的类型可能是 string | number | symbol 等,而我们在这里只关心那些类型为 string 的属性名。可以使用交叉类型(intersection types)来实现这个功能,具体的原理是:

  • K 满足 string 类型时,结果为 K 对应的字符串字面量类型
  • 否则,结果为 never,映射类型会过滤掉类型为 never 的键
type Fe<T> = string & T;
 
type Ef = Fe<"s">; // s
type Eff = Fe<1>; // never

枚举成员的透明性(opaque)

许多人可能遇到过这样的场景:

enum MyEnum {
  Foo = 'foo', Bar = 'bar', Baz = 'baz'
}

declare function myFunction(value: MyEnum): void;

// 我们希望用户可以这样传参:
myFunction(MyEnum.Foo); // 编译通过

// 我们也希望用户不必导入 MyEnum 就能传参
myFunction('foo'); // 编译不通过 :(

我们很容易认为函数参数中的 MyEnum 类型就是它的成员值的联合类型,即 'foo' | 'bar' | 'baz',因此也就觉得 myFunction('foo') 的用法是符合道理的。

然而,为什么 TypeScript 会报错呢?简单来说,这是一个设计决策 (opens in a new tab):TypeScript 的设计者希望枚举具备透明性(opaque),即枚举成员实际的值可以被修改却不会导致它的消费者出错,简单来说就是 TypeScript 不希望我们可以通过枚举的值去指代某个枚举成员,因为枚举的存在意义在于枚举成员的名字,而不是它的值

TypeScript 的这种「漏洞」其实是一个重要特性带来的副作用:数字枚举可以参与数学运算,就像下面的例子。

const _1 = MyEnum.Foo | MyEnum.Bar; // OK
const _2 = MyEnum.Foo * 2; // OK
const _3 = MyEnum.Baz & 0; // OK

从 5.0 开始,只有数字枚举成员对应的值的字面量才能被赋给枚举 (opens in a new tab)

Some tricks

指定 this 的类型

在 call/apply 一个 class function 的时候,this 变了,但却没有被检查出来 this 指向的错误。可以在方法的 this 隐式形参加上类型限制。

class Dong {
  name: string;
 
  constructor() {
    this.name = "dong";
  }
 
  hello(this: Dong) {
    return "hello, I'm " + this.name;
  }
}

并且也有一个 utility type ThisParameterType (opens in a new tab) 来提取一个函数所接受的 this 的类型

实现也非常简单:尝试匹配函数的类型中有没有显示定义 this 的类型,匹配出来就是 U,否则为 unknown

type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any
  ? U
  : unknown;

- 去掉已有的修饰

比如

type ToMutable<T> = { -readonly [Key in keyof T]: T[Key] };

Utility Types

NonNullable 过滤空类型

将一些包含 null undefined 的 union type 转化成不含这两个的 union。

type ss = "egg" | "flat" | "internet" | undefined | null;
type n = NonNullable<ss>; // 'egg' | 'flat' | 'internet'

Parameters 取函数的参数类型

declare function f1(arg: { a: number; b: string }): void;
 
type T0 = Parameters<() => string>;
 
type T0 = [];
type T1 = Parameters<(s: string) => void>;
 
type T1 = [s: string];
type T2 = Parameters<<T>(arg: T) => T>;
 
type T2 = [arg: unknown];
type T3 = Parameters<typeof f1>;
 
type T3 = [
  arg: {
    a: number;
    b: string;
  }
];

Capitalize

Capitalize<S extends string>

限制 S type 的首字母大写

let a: Capitalize<"xxxx"> = "Xxxx";

同样还有将 string type 进行大小写转换等

Uppercase<StringType>
Lowercase<StringType>
Capitalize<StringType>
Uncapitalize<StringType>

TS 4.1 的一些新东西

Ts 4.1 RC 了,抓紧来看看有什么新的内容吧

来自:https://www.infoq.cn/article/kHLmigWZ3fCyEdLQcvkD (opens in a new tab)

安装

npm install typescript@rc

或者直接安装 latest,现在都 4.1.2 了

踩坑

vscode 报错

仅是编辑器的语法报错

大家都知道 vscode 对 ts 的支持简直是亲生的一样,但是当我们在一个 workspace 安装了最新版本的 ts,那么需要找到右下角 typescript 旁边的版本,可以看到 vscode 内置的 ts 版本可能没那么高,选择 workspace 的就好,或者全局升级到最新。

模版字面量(Template Literal)类型

回顾:字面量类型,也就是这个类型他的值就是这几个具体的字面量的值

type Direction = "left" | "right" | "up" | "down";

模版字面量类型就是使用模版字符串,让字面量类型的具体值在模版中生成新的字面量类型

type World = "world";
 
type Greeting = `hello ${World}`;
// same as
//   type Greeting = "hello world";

在很多时候自动生成一组 gird 数据就很好用,不需要一个个枚举

type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
 
declare function setAlignment(
  params: `${VerticalAlignment}-${HorizontalAlignment}`
): void;
 
setAlignment("top-left");
// setAlignment("top-top"); 报错

在看一个来自马进大佬的例子

type Whitespace = " " | "\n" | "\r" | "\t";
 
type TrimStart<
  S extends string,
  P extends string = Whitespace
> = S extends `${P}${infer R}` ? TrimStart<R, P> : S;
 
type answerStartsWithSpace = "  yes" | " no" | "     ok";
let answer: TrimStart<answerStartsWithSpace> = "ok"

这里的 TrimStart 是一个泛型,将接受的类型,字符串的前缀给 trim 掉,注意这个类型判断其实是一个递归的,infer R 判断成功 S 所属的类型,也就是以类型 P 开头的值,如果不是,这个类型就是 S 类型,如果是,那就继续递归判断剩余部分 R,太妙了!

再看一个例子吧

function makeWatchedObject<T>(obj: T): T & PropEventSource<T> {
  return {
    ...obj,
    on(eventName: `${string & keyof T}Changed`, callback: () => void): void {
      // let a: string = eventName.replace("Changed", "");
      console.log(`on ${eventName}`);
      // usually will add callback to a cache cbs queue
      callback();
    },
  };
}
 
let person = makeWatchedObject({
  firstName: "Homer",
  age: 42, // give-or-take
  location: "Springfield",
});
 
type PropEventSource<T> = {
  on(eventName: `${string & keyof T}Changed`, callback: () => void): void;
};
 
person.on("ageChanged", () => {
  console.log(person.firstName);
});

我们也可以,通过泛型,让 callback 的参数能正确推断出类型,酷啊

type PropEventSource<T> = {
  on<K extends string & keyof T>(
    eventName: `${K}Changed`,
    callback: (newValue: T[K] | undefined) => void
  ): void;
};
// newAge 能自动推断出是 number 很酷
person.on("ageChanged", (newAge) => {
  if (newAge < 100) {
    console.log("newAge", newAge);
  }
});

也可以继续玩泛型

type hello = Uppercase<"yes" | "ok">;
type HELLO<T extends string> = `${Uppercase<T>}!!!`;
 
let aa: HELLO<hello> = "YES!!!"

映射类型 & key 重映射

先说说映射类型

type Options = {
  [K in "yes" | "ok" | "yesOk"]?: boolean;
};
// 相当于
// type Options = {
//   yes?: boolean;
//   ok?: boolean;
//   yesOk?: boolean;
// }
let options: Options = {
  ok: true,
};

可以快捷的创建一些类型

在 4.1 中,可以用 as 子句重新映射到不同类型的 key,结合模版类型,分分钟变形金刚

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];  // 保持原有属性类型
};
 
interface Person {
  name: string;
  age: number;
  location: string;
}
 
type LazyPerson = Getters<Person>;
// type LazyPerson = {
//   getName: () => string;
//   getAge: () => number;
//   getLocation: () => string;
// }
type RemoveNameField<T> = {
  [K in keyof T as Exclude<K, "name">]: T[K];
};
 
interface Cat {
  name: string;
  color: string;
}
 
type AnonymousCat = RemoveNameField<Cat>;

其实这个特性就是将 keyof 遍历出的类型映射到新的(自定义)类型

也可以 as 到一个常量上,可以做到将多个字段的 value 类型 union 到一个 key 上

type All<T> = {
  [k in keyof T as 'prop']: T[k];
}['prop'];

更多详见:PR (opens in a new tab)

Type language programming

参考文章:https://www.zhenghao.io/posts/type-programming (opens in a new tab)

文中以 TS 作为一门类型语言来看(图灵完备),也具编程语言的有很多特性,可以利用这些来更好的在日常开发中写类型,让 web app 变得更加 type safe and dependable

变量定义

type 或者 interface 声明的字面量其实是 type synonym or type alias(个人感觉是因为 duck inference 的原因)

local 变量可以通过 infer 来声明

type ConvertFooToBar<G> = G extends "foo" ? "bar" : never;
type ConvertBarToBaz<G> = G extends "bar" ? "baz" : never;
 
// infer defines the local variable Bar := ConvertFooToBar<T>
type ConvertFooToBaz<T> = ConvertFooToBar<T> extends infer Bar
  ? Bar extends "bar"
    ? ConvertBarToBaz<Bar>
    : never
  : never;

等价判断

extends

获取类型的 props 的类型

type Names = string[];
type Name = Names[number];
 
type Tuple = [string, number];
type Age = Tuple[1];
 
type User = { name: string; age: number };
type Name = User["name"];

函数

解释下不是函数类型,而是把一个 type 映射成另一个 type 的 type(map)

那就是用泛型了

Map 和 filter

文中举的例子很不错,JS 中将一个对象的所有 key 都转成 string(用到了 Object.fromEntries

const user = {
  name: "foo",
  age: 28,
};
 
function stringifyProp(object) {
  return Object.fromEntries(
    Object.entries(object).map(([key, value]) => [key, String(value)])
  );
}
 
const userWithStringProps = stringifyProp(user); // {name:'foo', age: '28'}

TS 中就是用 [K in keyof T] 来进行遍历

type User = {
  name: string;
  age: number;
};
 
type StringifyProp<T> = {
  [K in keyof T]: string;
};
 
type UserWithStringProps = StringifyProp<User>; // { name: string; age: string; }

同样也能进行判断,用 as 作为断言,个人理解这里相当于是 python 中 x if x == y else z

type FilterStringProp<T> = {
    [K in keyof T as T[K] extends string ? K : never]: string
}

Pattern matching

infer 作为模式匹配,很高级

type Str = 'foo-bar';
// infer use as the pattern matcher
type Bar = Str extends `foo-${infer rest}` ? `${rest}--Bar` : never; // 'bar--Bar'
 

递归代替循环

举个例子,填充数组的方法,可以通过递归来实现数组扩充,递归结束条件就是数量达到长度

// recursive function in JS
const fillArray = <T>(item: T, num: number, arr: T[] = []) => {
  return arr.length === num ? arr : fillArray(item, num, [...arr, item]);
};
 
type FillArray<
  Item,
  N extends number,
  Array extends Item[] = []
> = Array["length"] extends N ? Array : FillArray<Item, N, [...Array, Item]>;
 
type Foos = FillArray<"foo", 3>; // ["foo", "foo", "foo"]

Limits for recursion depth

Before TypeScript 4.5, the max recursion depth is 45 (opens in a new tab). In TypeScript 4.5, we have tail call optimization, and the limit increased to 999 (opens in a new tab).

Avoid type gymnastics in production code

不要在生产环境中玩 ts 体操,看了一些体操,真的挺有意思

  1. simulating a Chinese chess (象棋) (opens in a new tab)
  2. simulating a Tic Tac Toe game (opens in a new tab)
  3. implementing arithmetic (opens in a new tab)