TypeScript 类型编程

本文最后更新于:2020年12月29日 下午

前言

TypeScript 改变了吾辈对于类型系统的认知,它强大的类型系统使得类型本身也是可编程的。

最近 TypeScript 更新了一个大版本 v4,新增了一些非常强大的特性,让之前难以做到的事情也能够实现了。

  • 模板字符串类型
  • 递归类型

简介

参考:TypeScript 4.1

模板字符串类型

尤其适合在 CSS Properties 相关的类型定义中使用,例如 css 的 width,就可以使用模板字符串进行检查避免低级错误。

之前

type Width = number | string;
const i: Width = "1px";
const i2: Width = "1px2"; // 不会报错

现在

type Unit = "px" | "%" | "em" | "rem";
type Width = number | `${number}${Unit}`;
const i: Width = "1px";
const i2: Width = "1px2"; // TS2322: Type '"1px2"' is not assignable to type 'Width'.

该特性在 Grid 组件进行了实用,能够避免一些低级错误。

递归类型

事实上,递归类型的需求由来已久。例如典型的 Array.prototype.flat 函数的类型定义,或是函数式中部分应用函数的类型定义。

下面是一个将嵌套数组亚平的函数的类型定义示例(来自 TypeScript 官网)

type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {
  throw "not implemented";
}

// All of these return the type 'number[]':
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

实际的使用场景

在 i18next 中根据 key 获取翻译字符串

i18next 的类型定义

最近遇到了通过 i18n 框架获取翻译文本的需求,其中翻译文本通过一个对象的形式定义,所以吾辈就需要一种能够根据 key 获取到类型的方法。

需要支持以下情况

  • 根据 key 获取对应的文本
  • 根据 key 深层获取文本
  • 根据 key 获取文本并进行参数注入

下面是吾辈对 i18next 的封装

import zhCN from "../i18n/zhCN";
import en from "../i18n/en";
import i18next, { TOptions } from "i18next";

//region 类型定义

// returns the same string literal T, if props match, else never
type CheckDictString<T extends string, O> = T extends `${infer A}.${infer B}`
  ? A extends keyof O
    ? `${A}.${Extract<CheckDictString<B, O[A]>, string>}`
    : never
  : T extends keyof O
  ? T
  : never;

// returns the property value from object O given property path T
type GetDictValue<T extends string, O> = T extends `${infer A}.${infer B}`
  ? A extends keyof O
    ? GetDictValue<B, O[A]>
    : never
  : T extends keyof O
  ? O[T]
  : never;

// retrieves all variable placeholder names as tuple
type Keys<S extends string> = S extends ""
  ? []
  : S extends `${infer _}{{${infer B}}}${infer C}`
  ? [B, ...Keys<C>]
  : never;

// substitutes placeholder variables with input values
type Interpolate<
  S extends string,
  I extends Record<Keys<S>[number], string>
> = S extends ""
  ? ""
  : S extends `${infer A}{{${infer B}}}${infer C}`
  ? `${A}${I[Extract<B, keyof I>]}${Interpolate<C, I>}`
  : never;

//endregion

type Dict = typeof zhCN | typeof en;

export enum LanguageEnum {
  ZhCN = "zhCN",
  En = "en",
}

export class I18nLoader {
  constructor() {}

  /**
   * 加载国际化
   */

  async load(language: LanguageEnum) {
    await i18next.init({
      lng: language,
      resources: {
        en: {
          translation: en,
        },
        zhCN: {
          translation: zhCN,
        },
      },
    });
  }

  /**
   * 根据 key 获取翻译的文本
   * @param key
   */
  getText<K extends string>(
    key: keyof Dict | (K & CheckDictString<K, Dict>)
  ): GetDictValue<K, Dict>;
  getText<
    D extends Dict & Record<string, string>,
    K extends keyof D,
    I extends Record<Keys<D[K]>[number], string>
  >(k: K, args: I): Interpolate<D[K], I>;
  getText<K extends string>(
    key: keyof Dict | (K & CheckDictString<K, Dict>),
    args?: TOptions
  ): GetDictValue<K, Dict> {
    return i18next.t(key, args);
  }
}

使用起来很简单

const i18nLoader = new I18nLoader();
await i18nLoader.load(LanguageEnum.ZhCN);
console.log(i18nLoader.getText("hello")); // 你好

接下来,我们分析以下 CheckDictString

type CheckDictString<T extends string, O> = T extends `${infer A}.${infer B}`
  ? A extends keyof O
    ? `${A}.${Extract<CheckDictString<B, O[A]>, string>}`
    : never
  : T extends keyof O
  ? T
  : never;
  1. 传入泛型参数 TOT 必须继承自 string
  2. 判断 T 是否继承自 ${infer A}.${infer B},即判断 T 是否包含 .,并解构得到 A(第一个 . 之前),B(第一个 . 之后,可能还包含 .
    1. 如果是,则继续判断 A 是否为传入对象的字段
      1. 如果是,则继续递归检查 B 是否为 O[A] 的一个字段
      2. 否则,返回 never
    2. 否则,则判断 T 是否是 O 的字段
      1. 如果是,则返回 T
      2. 否则,返回 never

可以看到,如果检查出现错误,则返回 never,但我们传入的 string 是不能合并为 never 的,这将会导致 ts 类型检查出错(其它的类型基本上也是一样的推导方式)。


附录

虽然看起来不错,那么这个类型是否满足我们简化 i18next 使用的需求呢?
实际上没有。即便有如此强大的类型系统,但它仍然不足以满足特别灵活的需求。实际使用时仍发现以下问题:

  • 嵌套对象的参数注入没有进行检查
  • 参数注入的翻译文本没有提示注入参数

解决方案有两个方向

  • linter rule
  • code generate

下面是一个简单的对比

分类 typescript linter rule code generate
使用 直接使用 通过 eslint 插件 通过 cli 命令行
复杂度 一般(不用了解 ast 较高
适用场景 绝大多数场景 容易编写的少量代码的检查 大量重复可自动化生成的代码

使用类型系统解析 json 字符串(好玩性质)

twitter 上 甚至有人使用 TypeScript 的模板字符串和递归类型解析了 json 字符串。

type ParserError<T extends string> = { error: true } & T;
type EatWhitespace<State extends string> = string extends State
  ? ParserError<"EatWhitespace got generic string type">
  : State extends ` ${infer State}` | `\n${infer State}`
  ? EatWhitespace<State>
  : State;
type AddKeyValue<
  Memo extends Record<string, any>,
  Key extends string,
  Value extends any
> = Memo & { [K in Key]: Value };
type ParseJsonObject<
  State extends string,
  Memo extends Record<string, any> = {}
> = string extends State
  ? ParserError<"ParseJsonObject got generic string type">
  : EatWhitespace<State> extends `}${infer State}`
  ? [Memo, State]
  : EatWhitespace<State> extends `"${infer Key}"${infer State}`
  ? EatWhitespace<State> extends `:${infer State}`
    ? ParseJsonValue<State> extends [infer Value, `${infer State}`]
      ? EatWhitespace<State> extends `,${infer State}`
        ? ParseJsonObject<State, AddKeyValue<Memo, Key, Value>>
        : EatWhitespace<State> extends `}${infer State}`
        ? [AddKeyValue<Memo, Key, Value>, State]
        : ParserError<`ParseJsonObject received unexpected token: ${State}`>
      : ParserError<`ParseJsonValue returned unexpected value for: ${State}`>
    : ParserError<`ParseJsonObject received unexpected token: ${State}`>
  : ParserError<`ParseJsonObject received unexpected token: ${State}`>;
type ParseJsonArray<
  State extends string,
  Memo extends any[] = []
> = string extends State
  ? ParserError<"ParseJsonArray got generic string type">
  : EatWhitespace<State> extends `]${infer State}`
  ? [Memo, State]
  : ParseJsonValue<State> extends [infer Value, `${infer State}`]
  ? EatWhitespace<State> extends `,${infer State}`
    ? ParseJsonArray<EatWhitespace<State>, [...Memo, Value]>
    : EatWhitespace<State> extends `]${infer State}`
    ? [[...Memo, Value], State]
    : ParserError<`ParseJsonArray received unexpected token: ${State}`>
  : ParserError<`ParseJsonValue returned unexpected value for: ${State}`>;
type ParseJsonValue<State extends string> = string extends State
  ? ParserError<"ParseJsonValue got generic string type">
  : EatWhitespace<State> extends `null${infer State}`
  ? [null, State]
  : EatWhitespace<State> extends `"${infer Value}"${infer State}`
  ? [Value, State]
  : EatWhitespace<State> extends `[${infer State}`
  ? ParseJsonArray<State>
  : EatWhitespace<State> extends `{${infer State}`
  ? ParseJsonObject<State>
  : ParserError<`ParseJsonValue received unexpected token: ${State}`>;
type ParseJson<T extends string> = ParseJsonValue<T> extends infer Result
  ? Result extends [infer Value, string]
    ? Value
    : Result extends ParserError<any>
    ? Result
    : ParserError<"ParseJsonValue returned unexpected Result">
  : ParserError<"ParseJsonValue returned uninferrable Result">;

type Json = ParseJson<'{ "key1": ["value1", null], "key2": "value2" }'>; // type Json = { key1: ["value1", null]; } & { key2: "value2"; }

我想后端语言(Java/GoLang)至今也没有出现如此复杂的类型定义。

总结

虽然 TypeScript 的类型系统已经如此强大,但它并非没有局限性,像在上面的 在 i18next 中根据 key 获取翻译字符串 便是一例。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!