TypeScript 之 String Literal Types

2,033 阅读6分钟

CC: 快手EE FuNn1esT

TypeScript 之 String Literal Types

String Literal Types 意思是字符串字面量类型。在介绍字符串字面量之前,我们先了解一下 Literal Types ,也就是字面量类型

Literal Types

字面量类型指的是集合类型中的更具体的子类型,而集合类型顾名思义就是某种类型的集合 (如所有的字符串都是 string 类型,也就是说所有的字符串都在 string 类型的集合中)。目前 TypeScript 支持的 3 种字面量类型有 String Literal TypesNumeric Literal TypesBoolean Literal Types,分别对应集合类型 stringnumberboolean

String Literal Types

String Literal Types 就是字符串字面量类型,属于 string 类型的子类型。

const str1 = 'hello'; // => 'hello'
const str2: string = str1; // => string

type IsSub<L, R> = L extends R ? true : false;
type T1 = IsSub<typeof str1, typeof str2>; // => true
type T2 = IsSub<typeof str2, typeof str1>; // => false

上例中 str1 类型为 'hello',是字符串字面量类型,属于 string 类型的子类型。而 str2 类型为 string 类型。我们可以通过 Conditional Types 来判断一个类型是否是另外一个类型的子类型,可以看到判断的结果是和图例一样的。

上例中 Conditional Types 判断的是 L 类型是否可以分配给 R 类型 (兼容性)。而在 TypeScript 中有两种兼容性: 子类型和赋值,在大多数情况下子类型和赋值是等价的。它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和 any 来回赋值,以及 enum 和对应数字值之间的来回赋值。

使用字串符字面量类型,我们可以限定字符串为指定的固定值。 在实际应用中,字符串字面量类型可以与联合类型,类型守卫和类型别名很好的配合,也可以将字符串字面量类型当做特殊的类型。 通过结合使用这些特性,你可以实现类似枚举类型的字符串。

type Animal = 'dog' | 'cat';

function say(animal: Animal) {
  // TODO
}

say('dog');
say('pig'); // => Argument of type '"pig"' is not assignable to parameter of type 'Animal'.
say('duck' as string); // => Argument of type 'string' is not assignable to parameter of type 'Animal'.

在上面的例子中,我们将 say 函数的参数限定为字符串字面量类型 dogcat 的联合类型。可以看到当函数参数不是限定的字符串字面量类型时,参数校验是不能通过的。

另外 TypeScript 编译器在控制流语句分析中(if三目运算符)、switch 等可以将联合类型收敛到具体的某些类型。

type Animal = 'dog' | 'cat' | 'duck';

function say(animal: Animal) {
  if (animal === 'dog') {
    //
  } else {
    animal; // => 'cat' | 'duck'
  }
}

如上例中,在 else 作用域中 animal 的类型从联合类型 'dog' | 'cat' | 'duck' 收敛到了 'cat' | 'duck'

在 redux 相关的代码中,如果我们给 action 具体的联合类型,那么在 reducer 的处理函数中就可以类型收敛到具体的 action。

const initialState = {
  count: 0,
};

type Action = { type: 'INC'; payload: { n1: number } } | { type: 'DEC'; payload: { n2: number } };

const reducer = (state = initialState, action: Action) => {
  switch (action.type) {
    case 'INC': {
      action; // => { type: 'INC'; payload: { n1: number } }
      return state;
    }
    case 'DEC': {
      action; // => { type: 'DEC'; payload: { n2: number } }
      return state;
    }
    default: {
      return state;
    }
  }
};

Type Safe Redux

在掌握了 String Literal Types 的相关特性后,我们可以尝试实现一个类型安全的 FSA 以及 redux 所需要的 action creatorreducer

// Actions
const INC = 'INC';
const DEC = 'DEC';

// Action Creators
const inc = (n: number) => ({ type: INC, payload: { n1: n } });
const dec = (n: number) => ({ type: DEC, payload: { n2: n } });

type Action = ReturnType<typeof inc> | ReturnType<typeof dec>;

const initialState = {
  count: 0,
};

// Reducer
const reducer = (state = initialState, action: Action) => {
  switch (action.type) {
    case INC: {
      action; // => Action
      return state;
    }
    case DEC: {
      action; // => Action
      return state;
    }
    default: {
      return state;
    }
  }
};

但是我们发现,在 switch 内已经没法推断出真实的 action 类型了。这是为什么呢?

与之前 redux reducer 的例子不同,这里的 Action 类型使用的是 action creator 推断出来的,但是在 swtich 控制流语句中 action 的类型并没有收敛到具体的类型,反而是 Action 类型。这是因为 TypeScript 编译器在无法推断出具体类型时,会将类型扩大到父类型,甚至到 any 类型 (any 类型是 Top Type,任何值都是 any 的子类型)。如:

  1. String Literal Types => string
  2. Numeric Literal Types => number
  3. Boolean Literal Types => boolean
const inc: (n: number) => {
    type: string;
    payload: {
        n1: number;
    };
}

这里其实是因为 action creator 中的 type 类型被扩大到了 string 类型,因此在 switch 控制流中无法收敛到具体的类型了。

那么我们还有没有办法类型化 redux 的相关代码呢?有!

1. const 上下文声明

使用 const 上下文声明我们可以让 stringnumberboolean 的类型推断时不再收敛。

// Actions
-const INC = 'INC';
+const INC = 'INC' as const;
-const DEC = 'DEC';
+const DEC = 'DEC' as const;

因此 Action Creators 的类型也发生了变化,这样也就能正常的使用 Type Narrowing

const inc: (n: number) => {
    type: "INC";
    payload: {
        n1: number;
    };
}

2. Generic Constraints

如果觉得每一个 action 名称都要单独声明 as const 太麻烦的话,还可以使用泛型约束来推断出具体的类型。通过函数的泛型限制,TypeScript 编译器会推断出更具体的类型。在这里需要将 type 类型的泛型声明为 extends string,此时如果 type 传入的是字符串字面量类型,函数返回的类型就会是字符串字面量类型。

+const createAction = <T extends string, P extends any>(type: T, payload: P) => ({ type, payload });

// Action Creators
-const inc = (n: number) => ({ type: INC, payload: { n1: n } });
+const inc = (n: number) => createAction(INC, { n1: n });
-const dec = (n: number) => ({ type: DEC, payload: { n2: n } });
+const dec = (n: number) => createAction(DEC, { n2: n });

3. 恼人的 Type Widening

在 redux 中,每个 action 应该有唯一的 type,为了保证 type 唯一,我们一般会在 type 拼上当前模块的 namespace。如:

+const NAMESPACE = 'module';

// Actions
-const INC = 'INC';
+const INC = `${NAMESPACE}/INC`; // string
-const DEC = 'DEC';
+const DEC = `${NAMESPACE}/DEC`; // string

但是这个时候我们发现 INC 的类型扩大成了 string 类型,并且也无法使用 const 关键字。这是由于现在 TypeScript 类型系统的限制导致的。因此为了使得 action type 唯一,我们不得不得写很多样板代码,手动添加 namespace。

+const INC = 'module/INC'; // => 'module/INC'
+const DEC = 'module/DEC'; // => 'module/DEC'

Template Literal Types

最近 Anders Hejlsberg 老爷子给 TypeScript PR 了一个新的特性,就是模版字面量类型,预计在 TypeScript 4.1 中支持。模版字面量类型也是一个特殊的字符串字面量类型,属于 string 类型的子类型。可以像使用模版字符串的方式设置模板字面量类型。

type Getter<T extends string> = `get${T}`;
type GetA = Getter<'A'>; // 'getA'

通过给 Getter 类型传入指定的字符串字面量类型,可以推断出拼接的新类型。

可以看到这正好是我们 redux 类型化需要的特性。现在我们来改写一下之前的例子:

const NAMESPACE = 'module';

const createGen = <N extends string>(namespace: N) =>
    <T extends string>(type: T) => `${namespace}/${type}` as `${N}/${T}`;

const create = createGen(NAMESPACE);

// Actions
const INC = create('INC'); // => 'module/INC'
const DEC = create('DEC'); // => 'module/DEC'

const createAction = <T extends string, P extends any>(type: T, payload: P) => ({ type, payload });

const inc = (n: number) => createAction(INC, { n1: n });
const dec = (n: number) => createAction(DEC, { n2: n });

type Action = ReturnType<typeof inc> | ReturnType<typeof dec>;

const initialState = {
  count: 0,
};

// Reducer
const reducer = (state = initialState, action: Action) => {
  switch (action.type) {
    case INC: {
      action; // => { type: 'module/INC'; payload: { n1: number }; }
      return state;
    }
    case DEC: {
      action; // => { type: 'module/DEC'; payload: { n2: number }; }
      return state;
    }
    default: {
      return state;
    }
  }
};

在重构后的代码中,createGen 是一个高阶函数,接受 namespace 后,返回一个函数。这个函数接受一个字符串,返回拼接后的 action 名称。通过模版字面量类型,我们可以推断出具体的拼接后的 action 名称,而不会扩大成 string 类型。

从模版字面量类型的特性可以看出来这个特性适用于字符串字面量类型的推断,可以用于从已知的推断出需要的类型。比如说 qs 解析库、项目路由的类型化。

我们可以使用模版字面量类型实现一个简单的类型安全的 url search 参数解析函数

type GetQuery<T extends string> = T extends `${infer L}?${infer R}` ? R : never;

我们先简单的将 url 的 search 参数以 ? 分割。如果能推断出 ? 右侧的字符串则返回,否则返回 never

type S1 = GetQuery<'/home/list?k1=v1'>; // => 'k1=v1'
type S2 = GetQuery<'/home/list'>; // => never

可以看到 GetQuery 在 url 有 ? 的情况下可以推断出 search 的参数 k1=v1

type ParseQuery<T extends string> = string extends T 
  ? []
  : T extends ''
  ? [] 
  : T extends `${infer K}=${infer V}&${infer Rest}` 
  ? [{ key: K, value: V }, ...ParseQuery<Rest>] 
  : T extends `${infer K}=${infer V}` 
  ? [{ key: K, value: V }]
  : never;

type R1 = ParseQuery<GetQuery<'/home/list?k1=v1&k2=v2'>>; // => [{ key: 'k1'; value: 'v1'; }, { key: 'k2'; value: 'v2'; }]
type R2 = ParseQuery<GetQuery<'/home/list?k1=v1&'>>; // => [{ key: 'k1'; value: 'v1'; }]
type R3 = ParseQuery<GetQuery<'/home/list?k1=v1&k2'>>; // never

search 参数解析其实是个简单的 DSL 解析,就是个有限状态机。首先我们确定能够解析的最小单元是 key=value,通过模版字面量类型的特性可以很简单的推断出 = 左侧和右侧的字符串。对于 search 参数中多个参数的解析可以使用 Conditional Types 递归解析特性配合模版字面量类型,找到 & 分割符后,左侧部分作为已解析参数,右侧继续递归解析剩余 search 参数即可。ParseQuery 解析流程如下:

  1. 如果是 string 类型(没法进行类型推断)返回 [] 空解析结果。否则
  2. 如果是空字符串返回 [] 空解析结果。否则
  3. 如果能解析出 keyvalue 且为多个 search 参数,则返回当前解析结果拼接剩余 search 参数解析结果。否则
  4. 如果能解析出 keyvalue 则返回当前解析结果。否则
  5. 返回 never

由于 TypeScript 类型系统的限制,目前只支持递归深度 50 层的解析。