有点类似这个问题 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
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 & {} 看起来很奇怪,{} 表示非空类型,即任意非 null 或 undefined 的类型。二者做交叉如何理解。我们逐步分析:
{} 表示除了 null 和 undefined 之外的任何值:
✔️
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