【问题标题】:Narrowing a return type from a generic, discriminated union in TypeScript在 TypeScript 中从通用的、可区分的联合中缩小返回类型
【发布时间】:2018-02-28 22:24:18
【问题描述】:

我有一个类方法,它接受单个参数作为字符串并返回一个具有匹配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 union 相同。如果我要求用户提供我可以使用的预定义映射类型,那会起作用,但这不是一个选项,因为在实践中这将是一个非常糟糕的开发人员体验。 (工会很大)


这个用例可能看起来很奇怪。我试图将我的问题提炼成更易使用的形式,但我的用例实际上是关于 Observables 的。如果您熟悉它们,我会尝试更准确地键入ofType operator provided by redux-observable。它基本上是filter() on the type property 的简写。

这实际上与 Observable#filterArray#filter 缩小类型的方式非常相似,但 TS 似乎明白这一点,因为谓词回调具有 value is S 返回值。目前还不清楚我如何在这里调整类似的东西。

【问题讨论】:

    标签: typescript typescript-typings discriminated-union


    【解决方案1】:

    很遗憾,您无法使用联合类型(即type MyActions = ExampleAction | AnotherAction;)实现此行为。

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

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

    const result2 = items.doSomething<ExampleAction>('Example');
    

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

    【讨论】:

    • 谢谢!我相信当你有一个你缩小的歧视工会时,这句话不适用。 typescriptlang.org/docs/handbook/… 但我的问题是 TS 目前并没有推断出缩小的类型,而是要求我明确地提供它(正如你提到的那样)。我怀疑这应该是可能的原因是 TS 似乎只接受正确的类型作为通用参数——也就是说,如果我传递它而不是 ExampleAction 的形状,它会拒绝它。
    • 例如items.doSomething&lt;AnotherAction&gt;('Example') 正确生成:Argument of type 'Example' is not assignable to parameter of type 'Another'。我最好的猜测是它目前不支持推断缩小,即使它有类型信息来支持。
    • 是的,似乎获得有区别的联合的唯一方法是立即覆盖代码中的案例。也许,他们有一天会支持你想要的功能。不过,我会坚持使用更明确的方式。
    【解决方案2】:

    设置有点冗长,但我们可以通过类型查找实现您想要的 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);
    

    【讨论】:

    • 我相信这与我提到的相同:“如果我要求用户提供我可以使用的预定义映射类型,那将起作用,但这不是一个选项,因为在实践中它将是一个非常糟糕的开发者体验。(工会很大)“不是吗?
    • 为了澄清一点,开发人员已经必须提供所有操作的联合,因此这将要求他们提供该联合以及额外的类型映射。除非有办法从该地图的值创建一个联合?假设这是不可能的,这对于开发人员来说将是一个难以接受的要求——我想他们更喜欢显式的通用传递 items.doSomething&lt;ExampleAction&gt;('Example')
    • 事实证明,从类型映射中获取联合是有可能的(有一些警告)。看看丹尼尔的疯狂回答 lololol
    【解决方案3】:

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

    具体来说,我们可以在这里做的是在动作标签(即"Example""Another")和它们各自的payload之间添加一个表格。

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

    【讨论】:

    • 对于后人,我真正想要的确实目前不被TS支持,这里有一个现有的ticket:github.com/Microsoft/TypeScript/issues/17915这个解决方案被接受了,因为它提供了最灵活的折衷方案,虽然我不确定我是否可以(或想要)在实践中使用它。
    • 这也很聪明
    • Unionize&lt;T&gt; 不只是 T[keyof T] 的任何原因?
    • 我实际上不知道为什么我不写@jcalz。我已经编辑了答案。
    • 如何扩展此解决方案以适用于 keyof Tarray 而不是单个元素?意思是,该函数获取一个可能的对象类型(判别)值的数组,如果其中任何一个在给定对象(判别联合类型的元素)中有效,则可以,否则抛出错误。
    【解决方案4】:

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

    如果可以将类分组为单个对象的属性,那么接受的答案也会有所帮助。我喜欢里面的Unionize&lt;T&gt; 技巧。

    为了解释实际问题,让我把你的例子缩小到这个范围:

    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

    【讨论】:

    • 很好的说明! +1
    【解决方案5】:

    从 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 来自此答案https://stackoverflow.com/a/50125960/20489 的 NarrowAction 类型的想法

    【讨论】:

    • 这里的关键是,当 typeguard 只需要允许 U 类型的内容时,使用本身具有通用输入 (T) 的 typeguard 并返回以下表格 input is T extends U ? T : never。这将缩小包含泛型的类型 - 完全解决了我的问题!
    猜你喜欢
    • 2021-04-29
    • 1970-01-01
    • 2017-04-10
    • 1970-01-01
    • 2021-09-03
    • 2021-02-26
    • 2020-03-21
    • 1970-01-01
    • 2021-12-14
    相关资源
    最近更新 更多