我正在为较低级别的库开发 API 包装器,该库使用枚举将人类可读的键映射到底层值。在我们的 API 中,我想屏蔽 logging/etc 中的所有底层值,因此我只想在我们的一端使用枚举键。我想动态创建一个对象,该对象采用原始枚举并生成一个键/值相等的新对象。我还想强制将父对象键像枚举一样传递给参数,而不是允许直接使用字符串值。
enum Colors {
red = '$r5',
green = '$g2',
lightBlue = '$b9',
darkBlue = '$b1',
}
function getColor(color: Colors) {
return color;
}
getColor(Colors.darkBlue); // passes
getColor('darkBlue'); // fails as expected
function createKeyEnum<E>(e: E): {[k in keyof E]: k extends keyof E ? k : never} {
return Object.keys(e).reduce((p, v) => {
(p as any)[v] = v;
return p;
}, {} as {[k in keyof E]: k extends keyof E ? k : never});
}
const NewColors = createKeyEnum(Colors);
type NewColors = keyof typeof NewColors;
// forward mapping
function getOldColor(color: NewColors) {
return Colors[color];
}
// reverse mapping
function getNewColor(color: Colors) {
const reverse: any = {};
Object.entries(Colors).forEach(([k, v]) => reverse[v] = k);
return reverse[color] as NewColors;
}
getOldColor(NewColors.darkBlue); // passes
getOldColor('darkBlue'); // passes, but should fail like an enum
const color = getNewColor(Colors.darkBlue);
// typeof color == 'red' | 'green' | 'lightBlue' | 'darkBlue'
// should be: typeof color == NewColors
这是否可以通过任何花哨的类型实现,或者枚举是一种纯粹的底层魔法,无法使用其他 TS 类型进行复制?在下面的示例中,我的 NewColors 类型(可以理解)只是文字键字符串的联合。虽然这有效 - 它确实允许用户直接使用字符串值。如何将其链接到父符号 NewColors 并强制像枚举一样直接从它派生值?
答案 0 :(得分:0)
您希望 createKeyEnum()
输出的对象具有值为 nominally typed 的属性。因此,虽然 NewColors.darkBlue
在运行时可能等同于 "darkBlue"
,但您希望编译器将它们视为不同的,因为它们具有不同的名称或声明站点.
相比之下,TypeScript 的大部分类型系统都是 structural,尽管名称或声明位置不同,但可以将两种类型视为兼容。 microsoft/TypeScript#202 上有一个长期开放的功能请求,要求为名义输入提供一些官方支持。
如您所见,enums 是一项功能,其中存在一定数量的名义输入。但是您无法以编程方式生成枚举,因此这不会直接为您工作。
你可以做的是"brand"一个字符串文字类型,通过intersecting它具有一些名义或名义上的类型。这种标记模拟了类型系统中的名义类型。
使用交集意味着标记类型将被视为可分配给字符串文字类型,但反之则不然。 (所以 NewColors extends 'red' | 'green' | 'lightBlue' | 'darkBlue'
应该是真的,但 'red' | 'green' | 'lightBlue' | 'darkBlue' extends NewColors
应该是假的。
根据相交类型,您可能需要在 createKeyEnum()
的实现中使用 type assertion 来让编译器相信您确实拥有该类型的值。毕竟,实际值只是一个字符串,重点是当我们使用字符串代替名义类型的值时编译器会抱怨。所以可能需要一些错误抑制。
假设我们有一个名为 Brand<T, U>
的类型别名,它为 T
加上某种也依赖于 U
的类型(因此 Brand<"darkBlue", Colors>
和 Brand<"darkBlue", SomeOtherEnum>
将彼此不兼容)。我们可以担心如何定义下面的 Brand
。现在,让我们假设我们拥有它。
然后你可以像这样实现createKeyEnum()
:
function createKeyEnum<E>(e: E) {
class foo { private bar = 0 }
return Object.keys(e).reduce((p, v) => {
(p as any)[v] = v;
return p;
}, {} as { [K in keyof E]: Brand<K, E> }); // <-- need to assert here
}
并且因为您有意使 NewColors
与 keyof typeof Colors
有点不兼容,所以您可能需要在索引到 Colors
时将前者显式扩大到后者以避免错误:>
function getOldColor(color: NewColors) {
const k: keyof typeof Colors = color; // widen here
return Colors[k]; // now we can use k as a key
}
这会给你你想要的行为:
getOldColor(NewColors.darkBlue); // passes
getOldColor('darkBlue'); // fails
const color = getNewColor(Colors.darkBlue);
// const color: NewColors
那么,我们应该如何实现Brand
?最简单的方法是使用“幻影”属性:
type Brand<T, U> = T & { _tag: U };
这使用结构类型来模拟名义类型。没有什么能阻止某人实际添加 _tag
属性,但想象有人通过 getOldColor(Object.assign("darkBlue", { _tag: Colors }))
绕过安全性是不太可能和奇怪的。如果有人遇到这样的麻烦,也许你应该让他们去做。
如果您真的想防止这种情况发生,可以将 class
与 a private
member 一起使用:
declare class BrandClass<U> { private constructor(); private _tag: U };
type Brand<T, U> = T & BrandClass<U>
由于 _tag
成员是 private
,编译器将 BrandClass
类型视为名义类型,因此您无法在不使用名称 {{ 的情况下获得 BrandClass
实例1}}:
BrandClass
并且您实际上无法构造它,因为构造函数也是 getOldColor(Object.assign("darkBlue", { _tag: Colors })); // error
:
private
这使得有人几乎不可能在不使用类型断言的情况下生成编译器视为 getOldColor(Object.assign("darkBlue", new BrandClass<typeof Colors>())); // error
的值。