缩小TypeScript

时间:2017-09-20 02:12:30

标签: typescript typescript-typings discriminated-union

我有一个类方法,它接受一个参数作为字符串,并返回一个具有匹配type属性的对象。此方法用于缩小区分的联合类型,并保证返回的对象始终是具有提供的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');

我也试着变得聪明,试图动态地建立一个“映射类型” - 这是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。它基本上是filter() on the type property的缩写。

这实际上与Observable#filterArray#filter如何缩小类型非常类似,但TS似乎认为这是因为谓词回调具有value is S返回值。目前尚不清楚我如何能够适应类似的东西。

5 个答案:

答案 0 :(得分:18)

与编程中的许多优秀解决方案一样,您可以通过添加一个间接层来实现此目的。

具体来说,我们可以在此处执行操作标记(即"Example""Another")及其各自的有效负载之间的表格。

type ActionPayloadTable = {
    "Example": { example: true },
    "Another": { another: true },
}

然后我们可以做的是创建一个帮助器类型,用一个映射到每个动作标签的特定属性标记每个有效负载:

type TagWithKey<TagName extends string, T> = {
    [K in keyof T]: { [_ in TagName]: K } & T[K]
};

我们将使用它在动作类型和完整动作对象之间创建一个表:

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>;

最后,我们可以继续学习你的课程。我们不是对操作进行参数化,而是在操作表上进行参数化。

declare class Example<Table> {
  doSomething<ActionName extends keyof Table>(key: ActionName): Table[ActionName];
}

这可能是最有意义的部分 - Example基本上只是将表格的输入映射到其输出。

总之,这里是代码。

/**
 * 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);

答案 1 :(得分:2)

这需要a change in TypeScript完全按照问题中的要求工作。

如果可以将类分组为单个对象的属性,则接受的答案也可以提供帮助。我喜欢那里的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.

您可以做的另一件事是将实际的类传递给函数。这是一个疯狂的例子:

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

答案 2 :(得分:1)

不幸的是,使用union类型(即minimum_master_nodes)无法实现此行为。

  

If we have a value that has a union type, we can only access members that are common to all types in the union.

但是,您的解决方案很棒。您只需使用这种方式来定义所需的类型。

type MyActions = ExampleAction | AnotherAction;

虽然你不喜欢它,但做你想做的事似乎是非常合法的方式。

答案 3 :(得分:1)

在设置上稍微冗长一点,但我们可以通过类型查找实现所需的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);

答案 4 :(得分:1)

从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');

注意:从这个答案https://stackoverflow.com/a/50125960/20489

获得@jcalz关于NarrowAction类型的想法