TypeScript 系列:如何让类型既支持提示既定字符串又能接受任意字符串

176 阅读3分钟

R-C.jpg 有点类似这个问题 antd 组件如何支持既能输入不存在的选项又能进行下拉框选择,但今天这个是 TS 类型问题。

比如我们有一个参数 role,支持传入 button img menu 的时候能触发补全提示,也能传入任意字符串,但不能是非字符串,这个类型如何写。

function setRole(role: IRole) {
  // ...
}

setRole("button"); // OK
setRole("img"); // OK
setRole("menu"); // OK
setRole("custom-role"); // OK

setRole(1); // NOT OK
setRole({}); // NOTOK

首先第一条很好实现,用字符串字面量类型的联合类型即可:

type IRole = "button" | "img" | "menu"

如果我们继续让其支持任意字符串类型,增加 string 将让整个类型扩大到所有字符串,显然不是我们想要的 🙅‍♂️。

type IRole = "button" | "img" | "menu" | string

因为 button img menu 都属于字符串,故以上等价于 type IRole = string 起不到提示的效果。

✨ 答案

type IRole = "button" | "img" | "menu" | (string & {})

这是也是 React 组件对 a11y 的 role 属性的类型定义。详见 @types/react/index.d.ts#L2898

image.png

React 类型定义总共有五处,其他几处典型也列出如下:

type HTMLAttributeAnchorTarget =
    | "_self"
    | "_blank"
    | "_parent"
    | "_top"
    | (string & {});

type HTMLInputTypeAttribute =
    | "button"
    | "checkbox"
    | "color"
    | "date"
    | "datetime-local"
    | "email"
    | "file"
    | "hidden"
    | "image"
    | "month"
    | "number"
    | "password"
    | "radio"
    | "range"
    | "reset"
    | "search"
    | "submit"
    | "tel"
    | "text"
    | "time"
    | "url"
    | "week"
    | (string & {});

🧠 原理

string & {} 看起来很奇怪,{} 表示非空类型,即任意非 nullundefined 的类型。二者做交叉如何理解。我们逐步分析:

{} 表示除了 nullundefined 之外的任何值:

✔️

const str: {} = "str" // OK
const n: {} = 1 // OK
const b: {} = true // OK
const f: {} = function() {} // OK
const sym: {} = Symbol() // OK
const o1: {} = {} // OK
const o2: {} = { key: "value" } // OK

🚫

const nll: {} = null // Type 'null' is not assignable to type '{}'.(2322)
const undef: {} = undefined // Type 'undefined' is not assignable to type '{}'.(2322)

也即是 {} == string | number | boolean | function | ....

string & {} 交集表示取二者公共交叉部分,故等价于 string,这时候和字面量做并集(阻止TS编译器吸收字面量)达到输入提示的效果,但整体来说仍然是字符串。

设计考量

通过交集达到让 TS type checker “不做优化”其实是一种 hacky 的方式。

那通过字面量和字符串的并集想要达到提示的效果是否合理?

  • 用户角度合理:从开发者角度来说,这是符合直觉的,如果提供了字面量当然要字面量优先。但 TS 团队不会去做。
  • 编译器不合理:但是从编译器的角度这又是冲突的。因为编译器总是会根据数学原理做优化,数学的纯粹性优先于实用性。

🎯 总结

  • "a" | "b" | "c" | string:TS 编译器将简化成 string 达不到提示字面量的效果。
  • "a" | "b" | "c" | (string & {}):阻止编译器简化或“坍缩”从而可以提示字面量,从类型上看整体上仍然等价于 string,但却增强了使用体验

🔨 泛化:LiteralUnion 类型工具

扩展到数字。接受既定数字也可以是任何数组,但是不能是非数字,那我们可以这样写:

type IRole = 0 | 1 | 2 | (number & {})

最后看一个例子来自 github.com/microsoft/T… 稍加改动使用空对象

type LiteralUnion<T extends U, U = string> = T | (U & {})

type Color = LiteralUnion<'red' | 'black'>

var c: Color = 'red'        // Has intellisense
var d: Color = 'any-string' // Any string is OK
var d: Color = { }          // error

type N = LiteralUnion<1 | 2, number> // Works with numbers too

📚 参考