在 TypeScript 中管理受歧视的联合

Manage discriminated unions in TypeScript

枚举不起作用

TypeScript 枚举接近我的需要,但它们并没有完全实现。我需要一组 known-at-compile-timestatic-at-runtime 的项目集,它们的作用类似于枚举,但它们是class 启用添加属性或方法等功能。

假设 TypeScript 4.4.3 用于此问题。

例子

举个例子,一家小型便利店有三个过道。每个过道都有一个 id 和一个 name:

// Aisle objects
name: 'food', aisleId: '4'
name: 'housewares', aisldId: '7'
name: 'camping', aisleId: '9'

当通道的 nameaisleId 包含在变量中时,Aisles class 上的一些静态辅助方法是有意义的:

static getByName(name: string): Aisle {
  // return the right Aisle, or return undefined, or maybe throw
}

static getById(aisleId: string): Aisle {
  // same, but for aisleId
}

(可能有 description 属性 和其他,但不会根据描述查找 Aisle 对象。)

如果某些代码想要使用特定的 Aisle,最好只写 Aisles.FoodAisles['7']。虽然可以使用上面的 getBy 方法,但是当 'food' 在编译时已知时必须使用 Aisles.getById('food') 并不是很好。如果它们必须在不同的枚举中,例如 AisleNames.FoodAisleIds['7'],那很好。

目标

如果能实现所有或大部分目标,那就太好了:

我的尝试

在我尝试实现这些目标的过程中,出现了几个问题。 (你可以看到 some of my noodling in the TypeScript Playground,但不是我尝试的所有内容都在那里。)

我尝试将泛型类型参数添加到 Aisle,例如 Aisle<'food'>,但是在将它们放入集合中时,如何为它们使用接口?例如,给定 interface Aisle<T extends AisleName> {} 和一个具体的 class ConcreteAisle<T extends AisleName> implements Aisle<T> {},不能将 ConcreteAisle<T> 的实例放入 Aisle[] 因为 Aisle 想要一个通用类型论点,但没有单一的泛型类型可以给出。工会会在那里工作吗,比如 Aisle<AisleName> 那里 type AisleName = 'food' | 'housewares' | 'camping'?然后 uniqueness/completeness 问题又出现了。 Set<Aisle> 不会 gua运行tee 唯一性。这就是“TypeScript 中的枚举不是很好”的用武之地。

enum Aisle as const 可以提供帮助,只要代码不需要 tsconfig.json 中的 "isolatedModules": true。所以这不是一个选择。

在使用方法 getAisleById()getAisleByName() 时也遇到了一些问题。使用 Aisle 个对象的 Set,并对它们执行 .map() 以便为 aisleId [=生成 Map<AisleName, Aisle>Map 运行 进入问题,即即使类型联合用于传入值,Map<K, V>#get 方法 returns 类型 V | undefined,需要某种变通方法来欺骗 TypeScript 进入相信结果不是 undefined 只要值是联合类型。我知道如何使用 return var is Type 作为 return 值或 asserts var is Type 的函数,但宁愿不将这些函数暴露给这些对象的消费者——这应该都是如果可能,AislesAisle 类似枚举结构的实现内部。

如有必要,我可以为每个 Aisle 编写一个单独的具体 class。那很好,并且可以让我在每个中对 aisleNameaisleId 进行硬编码,而不必将这些值传递给构造函数。但是“如何在一个充当可区分联合的集合中使用这些”问题变得更糟。

有没有人有什么想法?

为了能够区分类型的联合,类型之间必须至少有一个共同的 属性 具有字面值(布尔值、字符串、数字或唯一符号)。您在 Aisle.

中添加了 T extends ... 属性,走在了正确的道路上

这是一种可能的方法,可以从 Aisle class 中提取 idname 参数以获得最大的编译时可推断性:

// `id` and `name` will both be pulled out as constants here, allowing you to get
// their exact values later on.
class Aisle<Id extends number, N extends string> {
  constructor(readonly id: Id, readonly name: N) { }
}

// Here's an "enum" of statically known Aisles
const Aisles = {
  1: new Aisle(1, 'baking'),
  2: new Aisle(2, 'garden'),
  3: new Aisle(3, 'spices'),
  4: new Aisle(4, 'food'),
  5: new Aisle(5, 'toys'),
  6: new Aisle(6, 'cleaning'),
  7: new Aisle(7, 'housewares'),
  8: new Aisle(8, 'travel'),
  9: new Aisle(9, 'camping'),
} as const; // Don't forget this

// And here's a type that makes referencing specific Aisles easier
type Aisles = typeof Aisles;

// Like so
type AisleOneOrTwo = Aisles[1] | Aisles[2];

// Accessing a particular Aisle
const a0 = Aisles[1] // Aisle<1, "baking">

// Fields are resolved to their literal values here.
a0.id // 1
a0.name // "baking"

// Here's a full union type of your Aisles.
type AislesUnion = Aisles[keyof Aisles];

这里还有一个link to the playground

如您所见,enum values in TypeScript are really just primitive strings or numbers. They don't behave like enum in Java 只是 class 个实例。如果你想要类似枚举的 class 实例,你必须自己构建它们。

一种方法可能是不导出底层 class(这样您无法控制的新实例就不会到处出现)并确保它们的类型足够强,以便生成的枚举可以在这两个方面都表现得像 discriminated union by having a literal-valued discriminant property. Looks like you have two such properties, name and aisleId, so let's make this underlying Aisle class generic

class Aisle<N extends string, I extends string> {
    constructor(public name: N, public aisleId: I, public description: string) { }
    someMethod() {
        console.log("Hi, I'm " + this.name + " and my Id is " + 
          this.aisleId + " and my description is " + this.description);
    }
}

由于我们希望导出的 Aisles 类枚举对象具有每个 nameaisleId 值的键,我们可以编写一个辅助函数 returns 具有两个这样的键的对象,其中每个值都是相关的 class 实例:

function make<N extends string, I extends string>(name: N, aisleId: I, description: string) {
    const aisle = new Aisle(name, aisleId, description);
    return { [name]: aisle, [aisleId]: aisle } as Record<N | I, typeof aisle>;
}

请注意 return 类型必须是 asserted as Record<N | I, typeof aisle> because the compiler unfortunately widens computed property key types all the way to string; see microsoft/TypeScript#13948

现在我们可以构建导出的 Aisles 带有对象传播的枚举对象:

export const Aisles = {
    ...make("food", "4", 'All things edible'),
    ...make("housewares", "7", 'Make your home zing'),
    ...make("camping", "9", 'Get into the outdoors!')
} as const;

您可以检查 Aisles 以检查它是否包含您关心的所有键和值。

/* const Aisles: {
    readonly camping: Aisle<"camping", "9">;
    readonly 9: Aisle<"camping", "9">;
    readonly housewares: Aisle<"housewares", "7">;
    readonly 7: Aisle<"housewares", "7">;
    readonly food: Aisle<...>;
    readonly 4: Aisle<...>;
} */

如果您希望 Aisles 也是与 Aisles 对象的值相对应的类型,(enum 会自动执行此操作),您可以这样做:

export type Aisles = typeof Aisles[keyof typeof Aisles];

如果你想附加更多的类型到Aisles你可以这样做via a namespace;例如,NameId 类型对应于相关键的并集:

namespace Aisles {
    export type Name = Aisles extends infer A ? A extends Aisle<infer N, any> ? N : never : never;
    export type Id = Aisles extends infer A ? A extends Aisle<any, infer I> ? I : never : never;
}

export default Aisles

我希望这已包含您关心的大部分功能。您可以访问 Aisles:

nameaisleId
Aisles.food.someMethod(); 
// "Hi, I'm food and my Id is 4 and my description is All things edible"
Aisles[7].someMethod(); 
// "Hi, I'm housewares and my Id is 7 and my description is Make your home zing"

您可以使用 Aisles 类型作为可区分联合:

function processAisle(aisle: Aisles) {
    switch (aisle.name) {
        case "camping": return 0;
        case "food": return 1;
        // not all paths return a value unless you uncomment next line
        /* case "housewares": return 2; */
    }
}

您可以使用导出的 Aisles.Name 类型将键限制为仅 name 而不是 aisleId 属性:

function reImplementGetAisleByName<N extends Aisles.Name>(name: N) {
    return Aisles[name]
}
const camping = reImplementGetAisleByName("camping");
// const camping: Aisle<"camping", "9">

Playground link to code