原文地址:How to implement a generic ValueOf<T> helper type in TypeScript.
原文作者:Stephan Meijer
第一次尝试翻译啦,先挑一篇简单的开始。🐛🐛🐛
Sudhanshu 昨天在KCD Discord上问了一个关于 Typescrpit 有趣的问题:
是否可以将变量的类型限制为普通对象的值?
我提供了解决方案,但他想了解其中的原理,所以我就顺手写了这篇文章来与各位分享这点知识。
我们先从纯 Javascript 代码开始,看一个符合 Sudhanshu 要求的在运行时执行校验的例子
const SHAPES = {
SQUARE: 'square',
CIRCLE: 'circle',
};
const value = 'square';
// 校验 value 是否是 SHAPES 的 values 之一
const validValues = Object.values(SHAPES);
const isValid = validValues.includes(value);
if (!isValid) {
throw new TypeError(
`'value' should be one of: ${validValues.join(' | ')}`
);
}
以上代码将会在 value
不等于 square 或 circle 时抛出错误。运行时校验挺好的,但问题是这些工作是否可以通过 Typescrpit 在静态阶段完成。
哈哈,这当然可以啦!
限制对象的值
我们面临的第一个挑战是,我们在操作一个对象(object)而不是一个类型(type)。
所以,我们应该先从对象提取出类型,那么就该使用 typeof
。
const SHAPES = {
SQUARE: 'square',
CIRCLE: 'circle',
};
type Shape = typeof SHAPES;
现在类型 Shape
就是
type Shape = {
SQUARE: string;
CIRCLE: string;
}
但这还不是我想要的。
如果我们需要验证 value
是否包含在对象 SHAPES
的值(square | circle)中,我们当然需要验证。我们可以通过关键字as const
将对象声明为常量,加上该申明后,我们向 Typescript 保证我们不会在运行时改变这一对象,Typescript 会将它视为一种类似于枚举类型的对象。
const SHAPES = {
SQUARE: 'square',
CIRCLE: 'circle',
} as const;
加了as const
后,类型 Shape 就变为
type Shape = {
readonly SQUARE: 'square';
readonly CIRCLE: 'circle';
}
解释一下这里发生了什么:
- 首先,所有属性都被标记为只读
readonly
,此后再修改该对象都将会收到来自 typescript 的错误提示 - 其次,现在各属性都被限制为它们对应的枚举值,而不是之前的 string 类型。
有了它之后,我们就得到了一个可以使用的类型了。Typescript 并没有 valueof
这样的辅助类型,但我们知道它有一个 keyof
,看看它能不能帮到我们。
type keys = keyof Shape;
keyof
从以上类型 Shape
中获取它所有键的联合类型,所以类型 keys
就等同于:
type keys = 'SQUARE' | 'CIRCLE';
有了键,那么我们就能获取到值了。其实或许到这,你可能就已经知道该如何提取值并复用它了。
就比如,如果你想得到 SQUARE 的类型,那么你可以:
type Square = Shape['SQUARE']; // square
现在,你可以基于以上Square
类型声明一个新的联合类型,大家应该会这么写:
type ValidShapes = Shape['SQUARE'] | Shape['CIRCLE']; // square | circle
当然,你也可以改为这样更简短的方式:
type ValidShapes = Shape['SQUARE' | 'CIRCLE']; // square | circle
我们来总结一下。以上我们使用keyof
来获取反映Shpae
键的联合类型,以及一个比较简捷的从值创建联合类型的方法。现在我们再回头看以上最后一个代码片段,你会发现索引参数不过是一个联合类型。其实,其实我们可以直接将keyof
丢在里面。
汇总一下以上关键代码片段:
// 使用 as const 将对象声明为常量,因此 ts 将其识别为枚举类型
const SHAPES = {
SQUARE: 'square',
CIRCLE: 'circle',
} as const;
// 从对象 SHAPES 中创建一个类型
type Shape = typeof SHAPES;
// 从对象的键 (SQUARE | CIRCLE) 创建一个联合类型
type Shapes = keyof Shape;
// 从对象的值 (square | circle) 创建一个联合类型
type Values = Shape[Shapes];
然后我们就可以用类型 Values
来限定属性的类型了。
const shape: Values = 'circle';
当我们尝试给类型Values
限定的变量赋除 square 或 circle 之外的任何值时,typescript 都将会抛出错误。
那么,既然完成任务,今天的文章就结束啦!
多提一句,虽然运行时校验真的很棒,但是现在我们已经不再需要它了,因为如果我们赋了一个不被支持的值时,甚至都不能通过编译 🤷♂️。
通用的valueof
其实上面的做法已经很够用了,但,如果能让它实现复用岂不是更棒?于是,我决定再多写两行,用上在 typescript 中比较高级的泛型。
我们重复下解决方案:
type Shape = typeof SHAPES;
type Shapes = keyof Shape;
type Values = Shape[Shapes];
让我们尝试将它转成泛型的实现。
第一步先将 type 收敛成一行,其实就是之前提到的将keyof
丢在索引中。当然我们并不打算将typeof
也内联 ,虽然加上并不难,但是就会变得很复杂,有机会我下次单独讨论。
type Values = Shape[keyof Shape];
类型Values
其实与之前没差,用法也一样 const shape: Values = 'circle'
,现在看看泛型的写法:
type Values = Shape[keyof Shape];
type ValueOf<T> = T [keyof T];
我加了一些空格做前后对比,所以其实应该能一目了然:
- 首先,我们将类型变量 T 附加到类型上,这里的 T 是一种特殊的变量(不熟悉泛型的同学我多说一句,这里的 T 可以是任意的大写字母),它只适用于类型而不是值。
- 其次,我们不再使用具体的类型
Shape
,而是直接使用该变量 T 作为参数。其实这里就是使用 T 替换了Shape
。
很简单,就这些了,这回真的写完了。你可以放心的将 Valueof
添加到你的typescript项目工具类型声明中去了。
// e.g.: global.d.ts
type ValueOf<T> = T[keyof T];
// Shape 是类型
const circle: ValueOf<Shape> = 'circle';
const rectangle: ValueOf<Shape> = 'rectangle'; // err
// SHAPES 是 JS 对象
const circle: ValueOf<typeof SHAPES> = 'circle';
const rectangle: ValueOf<typeof SHAPES> = 'rectangle'; // err