在 TypeScript 中管理受歧视的联合
Manage discriminated unions in TypeScript
枚举不起作用
TypeScript 枚举接近我的需要,但它们并没有完全实现。我需要一组 known-at-compile-time 和 static-at-runtime 的项目集,它们的作用类似于枚举,但它们是class 启用添加属性或方法等功能。
假设 TypeScript 4.4.3 用于此问题。
例子
举个例子,一家小型便利店有三个过道。每个过道都有一个 id
和一个 name
:
// Aisle objects
name: 'food', aisleId: '4'
name: 'housewares', aisldId: '7'
name: 'camping', aisleId: '9'
当通道的 name
或 aisleId
包含在变量中时,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.Food
或 Aisles['7']
。虽然可以使用上面的 getBy
方法,但是当 'food'
在编译时已知时必须使用 Aisles.getById('food')
并不是很好。如果它们必须在不同的枚举中,例如 AisleNames.Food
或 AisleIds['7']
,那很好。
目标
如果能实现所有或大部分目标,那就太好了:
- 仅适用于传入
Aisle
实例的代码应该能够依赖环境声明,而不需要导入任何东西:function cleanAisle(aisle: Aisle) {}
不应该导入任何东西,因为它不是创建任何实例。
- 可以在某处公开所有通道的类似枚举的集合:
AisleList
也许。或 Aisles
。或者 AislesEnum
.
- 环境类型声明规范地推动整个代码库的类型安全。需要两个可能存在冲突的 type/class 信息来源,一个
.ts
公开枚举或类似枚举对象的文件,以及一个在环境 .d.ts
文件中的单独文件并不是很好隐藏枚举,两者都不是单一的规范来源。
- 如果存在任何不一致,则应该在某处出现类型错误:如果任何代码添加或删除了新通道,或者命名错误,或拼写错误,或者在不应该存在的地方有任何重复。
- 该方案的代码重复次数最少。例如,命名一个 属性 并不好,但将与字符串相同的名称传递给构造函数只是为了 class 实例可以用于 return 它自己的名称,例如
readonly food = new Aisle('food', '4')
重复 'food'
。这可能会导致 属性 和实例不匹配。理想的是 new Aisle<'food'>()
然后驱动实际的 name
属性 或使用名称作为代码符号,无处不在,派生自环境类型声明或 class。 (有一个 TypeScript 插件可以在编译时实现 nameof
,这可能会有帮助,但我还没有做到这一点。)
我的尝试
在我尝试实现这些目标的过程中,出现了几个问题。 (你可以看到 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
的函数,但宁愿不将这些函数暴露给这些对象的消费者——这应该都是如果可能,Aisles
和 Aisle
类似枚举结构的实现内部。
如有必要,我可以为每个 Aisle
编写一个单独的具体 class。那很好,并且可以让我在每个中对 aisleName
和 aisleId
进行硬编码,而不必将这些值传递给构造函数。但是“如何在一个充当可区分联合的集合中使用这些”问题变得更糟。
有没有人有什么想法?
为了能够区分类型的联合,类型之间必须至少有一个共同的 属性 具有字面值(布尔值、字符串、数字或唯一符号)。您在 Aisle
.
中添加了 T extends ...
属性,走在了正确的道路上
这是一种可能的方法,可以从 Aisle
class 中提取 id
和 name
参数以获得最大的编译时可推断性:
// `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
类枚举对象具有每个 name
和 aisleId
值的键,我们可以编写一个辅助函数 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
;例如,Name
和 Id
类型对应于相关键的并集:
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
:
的 name
和 aisleId
键
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">
枚举不起作用
TypeScript 枚举接近我的需要,但它们并没有完全实现。我需要一组 known-at-compile-time 和 static-at-runtime 的项目集,它们的作用类似于枚举,但它们是class 启用添加属性或方法等功能。
假设 TypeScript 4.4.3 用于此问题。
例子
举个例子,一家小型便利店有三个过道。每个过道都有一个 id
和一个 name
:
// Aisle objects
name: 'food', aisleId: '4'
name: 'housewares', aisldId: '7'
name: 'camping', aisleId: '9'
当通道的 name
或 aisleId
包含在变量中时,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.Food
或 Aisles['7']
。虽然可以使用上面的 getBy
方法,但是当 'food'
在编译时已知时必须使用 Aisles.getById('food')
并不是很好。如果它们必须在不同的枚举中,例如 AisleNames.Food
或 AisleIds['7']
,那很好。
目标
如果能实现所有或大部分目标,那就太好了:
- 仅适用于传入
Aisle
实例的代码应该能够依赖环境声明,而不需要导入任何东西:function cleanAisle(aisle: Aisle) {}
不应该导入任何东西,因为它不是创建任何实例。 - 可以在某处公开所有通道的类似枚举的集合:
AisleList
也许。或Aisles
。或者AislesEnum
. - 环境类型声明规范地推动整个代码库的类型安全。需要两个可能存在冲突的 type/class 信息来源,一个
.ts
公开枚举或类似枚举对象的文件,以及一个在环境.d.ts
文件中的单独文件并不是很好隐藏枚举,两者都不是单一的规范来源。 - 如果存在任何不一致,则应该在某处出现类型错误:如果任何代码添加或删除了新通道,或者命名错误,或拼写错误,或者在不应该存在的地方有任何重复。
- 该方案的代码重复次数最少。例如,命名一个 属性 并不好,但将与字符串相同的名称传递给构造函数只是为了 class 实例可以用于 return 它自己的名称,例如
readonly food = new Aisle('food', '4')
重复'food'
。这可能会导致 属性 和实例不匹配。理想的是new Aisle<'food'>()
然后驱动实际的name
属性 或使用名称作为代码符号,无处不在,派生自环境类型声明或 class。 (有一个 TypeScript 插件可以在编译时实现nameof
,这可能会有帮助,但我还没有做到这一点。)
我的尝试
在我尝试实现这些目标的过程中,出现了几个问题。 (你可以看到 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
的函数,但宁愿不将这些函数暴露给这些对象的消费者——这应该都是如果可能,Aisles
和 Aisle
类似枚举结构的实现内部。
如有必要,我可以为每个 Aisle
编写一个单独的具体 class。那很好,并且可以让我在每个中对 aisleName
和 aisleId
进行硬编码,而不必将这些值传递给构造函数。但是“如何在一个充当可区分联合的集合中使用这些”问题变得更糟。
有没有人有什么想法?
为了能够区分类型的联合,类型之间必须至少有一个共同的 属性 具有字面值(布尔值、字符串、数字或唯一符号)。您在 Aisle
.
T extends ...
属性,走在了正确的道路上
这是一种可能的方法,可以从 Aisle
class 中提取 id
和 name
参数以获得最大的编译时可推断性:
// `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
类枚举对象具有每个 name
和 aisleId
值的键,我们可以编写一个辅助函数 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
;例如,Name
和 Id
类型对应于相关键的并集:
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
:
name
和 aisleId
键
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">