Typescript 强制从父对象(如枚举)使用对象字符串

Typescript enforce object string used from parent object like enum

我正在为一个较低级别的库开发一个 API 包装器,它使用枚举将人类可读的键映射到底层值。在我们的 API 中,我想屏蔽 logging/etc 中的所有基础值,所以我只想在我们这边使用枚举键。我想动态创建一个对象,它采用原始枚举并生成一个 keys/values 相等的新对象。我还想强制将父对象键像枚举一样传递到参数中,而不是允许直接使用字符串值。

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 并强制值像枚举一样直接从它派生?

您希望 createKeyEnum() 输出的对象具有值为 nominally typed 的属性。因此,虽然 NewColors.darkBlue 在运行时可能等同于 "darkBlue",但您希望编译器将它们视为不同的,因为它们具有不同的 names声明站点.

相比之下,大多数 TypeScript 类型系统 structural, where two types can be seen as compatible despite having different names or declaration sites. There is a longstanding open feature request at microsoft/TypeScript#202 要求对名义类型提供一些官方支持。

如您所见,enums 是一项功能,其中有一定数量的名义打字。但是您不能以编程方式生成枚举,因此这不会直接为您工作。


可以做的是"brand" a string literal type by 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
}

并且因为您有意使 NewColorskeyof 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 })) 规避安全性。如果有人遇到这样的麻烦,也许你应该让他们去做。


如果你真的想阻止这种情况,你可以使用 classa private member:

declare class BrandClass<U> { private constructor(); private _tag: U };
type Brand<T, U> = T & BrandClass<U>

由于 _tag 成员是 private,编译器将 BrandClass 类型视为标称类型,因此不使用名称就无法获得 BrandClass 实例BrandClass:

getOldColor(Object.assign("darkBlue", { _tag: Colors })); // error

而且你实际上不能构造它,因为构造函数也是 private:

getOldColor(Object.assign("darkBlue", new BrandClass<typeof Colors>())); // error

这使得某人几乎不可能在不使用类型断言的情况下生成编译器视为 Brand<T, U> 的值。

Playground link to code