从 TypeScript 中的通用、有区别的联合缩小 return 类型
Narrowing a return type from a generic, discriminated union in TypeScript
我有一个 class 方法,它接受单个参数作为字符串,return 是一个具有匹配 type
属性 的对象。此方法用于缩小已区分的联合类型,并保证 returned 对象将始终是具有提供的 type
区分值的特定缩小类型。
我正在尝试为此方法提供一个类型签名,它将正确地从泛型参数中缩小类型范围,但在没有用户明确提供它应该是的类型的情况下,我没有尝试从可区分的联合中缩小它的范围缩小到。这行得通,但很烦人,感觉很多余。
希望这个最小复制能说明问题:
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = ExampleAction | AnotherAction;
declare class Example<T extends Action> {
// THIS IS THE METHOD IN QUESTION
doSomething<R extends T>(key: R['type']): R;
}
const items = new Example<MyActions>();
// result is guaranteed to be an ExampleAction
// but it is not inferred as such
const result1 = items.doSomething('Example');
// ts: Property 'example' does not exist on type 'AnotherAction'
console.log(result1.example);
/**
* If the dev provides the type more explicitly it narrows it
* but I'm hoping it can be inferred instead
*/
// this works, but is not ideal
const result2 = items.doSomething<ExampleAction>('Example');
// this also works, but is not ideal
const result3: ExampleAction = items.doSomething('Example');
我也尝试变聪明,尝试动态构建 "mapped type"——这是 TS 中的一个相当新的功能。
declare class Example2<T extends Action> {
doSomething<R extends T['type'], TypeMap extends { [K in T['type']]: T }>(key: R): TypeMap[R];
}
这会产生相同的结果:它不会缩小类型,因为在类型映射 { [K in T['type']]: T }
中,每个计算的 属性、T
的值不是 对于 K in
迭代的每个 属性 但只是相同的 MyActions
并集。如果我要求用户提供我可以使用的预定义映射类型,那会起作用,但这不是一个选项,因为在实践中这将是非常糟糕的开发人员体验。 (工会很大)
这个用例可能看起来很奇怪。我试图将我的问题提炼成更易用的形式,但我的用例实际上是关于 Observables 的。如果您熟悉它们,我会尝试更准确地键入 ofType
operator provided by redux-observable. It is basically a shorthand for a filter()
on the type
property.
这实际上与 Observable#filter
和 Array#filter
缩小类型的方式非常相似,但 TS 似乎明白了这一点,因为谓词回调具有 value is S
return 价值。目前尚不清楚我如何在此处改编类似内容。
遗憾的是,您无法使用联合类型(即 type MyActions = ExampleAction | AnotherAction;
)实现此行为。
不过,您的解决方案很棒。你只需要使用这种方式来定义你需要的类型。
const result2 = items.doSomething<ExampleAction>('Example');
虽然你不喜欢它,但它似乎是做你想做的很合法的方式。
设置有点冗长,但我们可以通过类型查找实现您想要的 API:
interface Action {
type: string;
}
interface Actions {
[key: string]: Action;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = {
Another: AnotherAction;
Example: ExampleAction;
};
declare class Example<T extends Actions> {
doSomething<K extends keyof T, U>(key: K): T[K];
}
const items = new Example<MyActions>();
const result1 = items.doSomething('Example');
console.log(result1.example);
与编程中的许多优秀解决方案一样,您可以通过添加一个间接层来实现。
具体来说,我们在这里可以做的是在动作标签(即 "Example"
和 "Another"
)及其各自的有效负载之间添加一个 table。
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
然后我们可以做的是创建一个辅助类型,用映射到每个动作标签的特定 属性 标记每个有效载荷:
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
我们将使用它在动作类型和完整动作对象本身之间创建 table:
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
这是一种更简单(尽管不太清楚)的写作方式:
type ActionTable = {
"Example": { type: "Example" } & { example: true },
"Another": { type: "Another" } & { another: true },
}
现在我们可以为每个操作创建方便的名称:
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
我们可以通过写
来创建联合
type MyActions = ExampleAction | AnotherAction;
或者我们可以通过编写
在每次添加新动作时避免更新联合
type Unionize<T> = T[keyof T];
type MyActions = Unionize<ActionTable>;
我们终于可以继续您的 class 了。我们不对动作进行参数化,而是对动作进行参数化 table。
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
这可能是最有意义的部分 - Example
基本上只是将 table 的输入映射到它的输出。
总而言之,这是代码。
/**
* Adds a property of a certain name and maps it to each property's key.
* For example,
*
* ```
* type ActionPayloadTable = {
* "Hello": { foo: true },
* "World": { bar: true },
* }
*
* type Foo = TagWithKey<"greeting", ActionPayloadTable>;
* ```
*
* is more or less equivalent to
*
* ```
* type Foo = {
* "Hello": { greeting: "Hello", foo: true },
* "World": { greeting: "World", bar: true },
* }
* ```
*/
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
type Unionize<T> = T[keyof T];
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
type MyActions = Unionize<ActionTable>
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
const items = new Example<ActionTable>();
const result1 = items.doSomething("Example");
console.log(result1.example);
这需要 a change in TypeScript 完全按照问题中的要求工作。
如果 classes 可以分组为单个对象的属性,那么接受的答案也可以提供帮助。我喜欢那里的 Unionize<T>
把戏。
为了解释实际问题,让我将您的示例缩小为:
class RedShape {
color: 'Red'
}
class BlueShape {
color: 'Blue'
}
type Shapes = RedShape | BlueShape;
type AmIRed = Shapes & { color: 'Red' };
/* Equals to
type AmIRed = (RedShape & {
color: "Red";
}) | (BlueShape & {
color: "Red";
})
*/
/* Notice the last part in before:
(BlueShape & {
color: "Red";
})
*/
// Let's investigate:
type Whaaat = (BlueShape & {
color: "Red";
});
type WhaaatColor = Whaaat['color'];
/* Same as:
type WhaaatColor = "Blue" & "Red"
*/
// And this is the problem.
您可以做的另一件事是将实际的 class 传递给函数。这是一个疯狂的例子:
declare function filterShape<
TShapes,
TShape extends Partial<TShapes>
>(shapes: TShapes[], cl: new (...any) => TShape): TShape;
// Doesn't run because the function is not implemented, but helps confirm the type
const amIRed = filterShape(new Array<Shapes>(), RedShape);
type isItRed = typeof amIRed;
/* Same as:
type isItRed = RedShape
*/
这里的问题是您无法获取 color
的值。您可以 RedShape.prototype.color
,但这将始终是未定义的,因为该值仅在构造函数中应用。 RedShape
编译为:
var RedShape = /** @class */ (function () {
function RedShape() {
}
return RedShape;
}());
即使你这样做了:
class RedShape {
color: 'Red' = 'Red';
}
编译为:
var RedShape = /** @class */ (function () {
function RedShape() {
this.color = 'Red';
}
return RedShape;
}());
并且在您的实际示例中,构造函数可能有多个参数等,因此也可能无法实例化。更不用说它也不适用于接口。
您可能不得不恢复到愚蠢的方式,例如:
class Action1 { type: '1' }
class Action2 { type: '2' }
type Actions = Action1 | Action2;
declare function ofType<TActions extends { type: string },
TAction extends TActions>(
actions: TActions[],
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = ofType(new Array<Actions>(), Action1, '1');
/* Same as if
var one: Action1 = ...
*/
或者在你的doSomething
措辞中:
declare function doSomething<TAction extends { type: string }>(
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = doSomething(Action1, '1');
/* Same as if
const one : Action1 = ...
*/
正如在对其他答案的评论中提到的,TypeScript 中已经存在用于修复推理问题的问题。我写了一条评论链接回这个答案的解释,并提供了问题的更高层次的例子 here.
从 TypeScript 2.8 开始,您可以通过条件类型来完成此操作。
// Narrows a Union type base on N
// e.g. NarrowAction<MyActions, 'Example'> would produce ExampleAction
type NarrowAction<T, N> = T extends { type: N } ? T : never;
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions =
| ExampleAction
| AnotherAction;
declare class Example<T extends Action> {
doSomething<K extends T['type']>(key: K): NarrowAction<T, K>
}
const items = new Example<MyActions>();
// Inferred ExampleAction works
const result1 = items.doSomething('Example');
注意:归功于@jcalz,从这个答案中获得了 NarrowAction 类型的想法
我有一个 class 方法,它接受单个参数作为字符串,return 是一个具有匹配 type
属性 的对象。此方法用于缩小已区分的联合类型,并保证 returned 对象将始终是具有提供的 type
区分值的特定缩小类型。
我正在尝试为此方法提供一个类型签名,它将正确地从泛型参数中缩小类型范围,但在没有用户明确提供它应该是的类型的情况下,我没有尝试从可区分的联合中缩小它的范围缩小到。这行得通,但很烦人,感觉很多余。
希望这个最小复制能说明问题:
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = ExampleAction | AnotherAction;
declare class Example<T extends Action> {
// THIS IS THE METHOD IN QUESTION
doSomething<R extends T>(key: R['type']): R;
}
const items = new Example<MyActions>();
// result is guaranteed to be an ExampleAction
// but it is not inferred as such
const result1 = items.doSomething('Example');
// ts: Property 'example' does not exist on type 'AnotherAction'
console.log(result1.example);
/**
* If the dev provides the type more explicitly it narrows it
* but I'm hoping it can be inferred instead
*/
// this works, but is not ideal
const result2 = items.doSomething<ExampleAction>('Example');
// this also works, but is not ideal
const result3: ExampleAction = items.doSomething('Example');
我也尝试变聪明,尝试动态构建 "mapped type"——这是 TS 中的一个相当新的功能。
declare class Example2<T extends Action> {
doSomething<R extends T['type'], TypeMap extends { [K in T['type']]: T }>(key: R): TypeMap[R];
}
这会产生相同的结果:它不会缩小类型,因为在类型映射 { [K in T['type']]: T }
中,每个计算的 属性、T
的值不是 对于 K in
迭代的每个 属性 但只是相同的 MyActions
并集。如果我要求用户提供我可以使用的预定义映射类型,那会起作用,但这不是一个选项,因为在实践中这将是非常糟糕的开发人员体验。 (工会很大)
这个用例可能看起来很奇怪。我试图将我的问题提炼成更易用的形式,但我的用例实际上是关于 Observables 的。如果您熟悉它们,我会尝试更准确地键入 ofType
operator provided by redux-observable. It is basically a shorthand for a filter()
on the type
property.
这实际上与 Observable#filter
和 Array#filter
缩小类型的方式非常相似,但 TS 似乎明白了这一点,因为谓词回调具有 value is S
return 价值。目前尚不清楚我如何在此处改编类似内容。
遗憾的是,您无法使用联合类型(即 type MyActions = ExampleAction | AnotherAction;
)实现此行为。
不过,您的解决方案很棒。你只需要使用这种方式来定义你需要的类型。
const result2 = items.doSomething<ExampleAction>('Example');
虽然你不喜欢它,但它似乎是做你想做的很合法的方式。
设置有点冗长,但我们可以通过类型查找实现您想要的 API:
interface Action {
type: string;
}
interface Actions {
[key: string]: Action;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions = {
Another: AnotherAction;
Example: ExampleAction;
};
declare class Example<T extends Actions> {
doSomething<K extends keyof T, U>(key: K): T[K];
}
const items = new Example<MyActions>();
const result1 = items.doSomething('Example');
console.log(result1.example);
与编程中的许多优秀解决方案一样,您可以通过添加一个间接层来实现。
具体来说,我们在这里可以做的是在动作标签(即 "Example"
和 "Another"
)及其各自的有效负载之间添加一个 table。
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
然后我们可以做的是创建一个辅助类型,用映射到每个动作标签的特定 属性 标记每个有效载荷:
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
我们将使用它在动作类型和完整动作对象本身之间创建 table:
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
这是一种更简单(尽管不太清楚)的写作方式:
type ActionTable = {
"Example": { type: "Example" } & { example: true },
"Another": { type: "Another" } & { another: true },
}
现在我们可以为每个操作创建方便的名称:
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
我们可以通过写
来创建联合type MyActions = ExampleAction | AnotherAction;
或者我们可以通过编写
在每次添加新动作时避免更新联合type Unionize<T> = T[keyof T];
type MyActions = Unionize<ActionTable>;
我们终于可以继续您的 class 了。我们不对动作进行参数化,而是对动作进行参数化 table。
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
这可能是最有意义的部分 - Example
基本上只是将 table 的输入映射到它的输出。
总而言之,这是代码。
/**
* Adds a property of a certain name and maps it to each property's key.
* For example,
*
* ```
* type ActionPayloadTable = {
* "Hello": { foo: true },
* "World": { bar: true },
* }
*
* type Foo = TagWithKey<"greeting", ActionPayloadTable>;
* ```
*
* is more or less equivalent to
*
* ```
* type Foo = {
* "Hello": { greeting: "Hello", foo: true },
* "World": { greeting: "World", bar: true },
* }
* ```
*/
type TagWithKey<TagName extends string, T> = {
[K in keyof T]: { [_ in TagName]: K } & T[K]
};
type Unionize<T> = T[keyof T];
type ActionPayloadTable = {
"Example": { example: true },
"Another": { another: true },
}
type ActionTable = TagWithKey<"type", ActionPayloadTable>;
type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];
type MyActions = Unionize<ActionTable>
declare class Example<Table> {
doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}
const items = new Example<ActionTable>();
const result1 = items.doSomething("Example");
console.log(result1.example);
这需要 a change in TypeScript 完全按照问题中的要求工作。
如果 classes 可以分组为单个对象的属性,那么接受的答案也可以提供帮助。我喜欢那里的 Unionize<T>
把戏。
为了解释实际问题,让我将您的示例缩小为:
class RedShape {
color: 'Red'
}
class BlueShape {
color: 'Blue'
}
type Shapes = RedShape | BlueShape;
type AmIRed = Shapes & { color: 'Red' };
/* Equals to
type AmIRed = (RedShape & {
color: "Red";
}) | (BlueShape & {
color: "Red";
})
*/
/* Notice the last part in before:
(BlueShape & {
color: "Red";
})
*/
// Let's investigate:
type Whaaat = (BlueShape & {
color: "Red";
});
type WhaaatColor = Whaaat['color'];
/* Same as:
type WhaaatColor = "Blue" & "Red"
*/
// And this is the problem.
您可以做的另一件事是将实际的 class 传递给函数。这是一个疯狂的例子:
declare function filterShape<
TShapes,
TShape extends Partial<TShapes>
>(shapes: TShapes[], cl: new (...any) => TShape): TShape;
// Doesn't run because the function is not implemented, but helps confirm the type
const amIRed = filterShape(new Array<Shapes>(), RedShape);
type isItRed = typeof amIRed;
/* Same as:
type isItRed = RedShape
*/
这里的问题是您无法获取 color
的值。您可以 RedShape.prototype.color
,但这将始终是未定义的,因为该值仅在构造函数中应用。 RedShape
编译为:
var RedShape = /** @class */ (function () {
function RedShape() {
}
return RedShape;
}());
即使你这样做了:
class RedShape {
color: 'Red' = 'Red';
}
编译为:
var RedShape = /** @class */ (function () {
function RedShape() {
this.color = 'Red';
}
return RedShape;
}());
并且在您的实际示例中,构造函数可能有多个参数等,因此也可能无法实例化。更不用说它也不适用于接口。
您可能不得不恢复到愚蠢的方式,例如:
class Action1 { type: '1' }
class Action2 { type: '2' }
type Actions = Action1 | Action2;
declare function ofType<TActions extends { type: string },
TAction extends TActions>(
actions: TActions[],
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = ofType(new Array<Actions>(), Action1, '1');
/* Same as if
var one: Action1 = ...
*/
或者在你的doSomething
措辞中:
declare function doSomething<TAction extends { type: string }>(
action: new(...any) => TAction, type: TAction['type']): TAction;
const one = doSomething(Action1, '1');
/* Same as if
const one : Action1 = ...
*/
正如在对其他答案的评论中提到的,TypeScript 中已经存在用于修复推理问题的问题。我写了一条评论链接回这个答案的解释,并提供了问题的更高层次的例子 here.
从 TypeScript 2.8 开始,您可以通过条件类型来完成此操作。
// Narrows a Union type base on N
// e.g. NarrowAction<MyActions, 'Example'> would produce ExampleAction
type NarrowAction<T, N> = T extends { type: N } ? T : never;
interface Action {
type: string;
}
interface ExampleAction extends Action {
type: 'Example';
example: true;
}
interface AnotherAction extends Action {
type: 'Another';
another: true;
}
type MyActions =
| ExampleAction
| AnotherAction;
declare class Example<T extends Action> {
doSomething<K extends T['type']>(key: K): NarrowAction<T, K>
}
const items = new Example<MyActions>();
// Inferred ExampleAction works
const result1 = items.doSomething('Example');
注意:归功于@jcalz,从这个答案中获得了 NarrowAction 类型的想法