前言

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

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

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

简介

参考:TypeScript 4.1open in new window

模板字符串类型

尤其适合在 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 的类型定义open in new window

最近遇到了通过 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

下面是一个简单的对比

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

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

twitter 上open in new window 甚至有人使用 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 获取翻译字符串 便是一例。