打字稿强制从父对象使用的对象字符串,如枚举

时间:2021-07-28 17:31:56

标签: typescript enums

我正在为较低级别的库开发 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 并强制像枚举一样直接从它派生值?

1 个答案:

答案 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
}

并且因为您有意使 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 实例1}}:

BrandClass

并且您实际上无法构造它,因为构造函数也是 getOldColor(Object.assign("darkBlue", { _tag: Colors })); // error

private

这使得有人几乎不可能在不使用类型断言的情况下生成编译器视为 getOldColor(Object.assign("darkBlue", new BrandClass<typeof Colors>())); // error 的值。

Playground link to code