Narrowing a return type from a generic, discriminated union in TypeScript

|▌冷眼眸甩不掉的悲伤 提交于 2019-11-30 17:18:35

Like many good solutions in programming, you achieve this by adding a layer of indirection.

Specifically, what we can do here is add a table between action tags (i.e. "Example" and "Another") and their respective payloads.

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

then what we can do is create a helper type that tags each payload with a specific property that maps to each action tag:

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

Which we'll use to create a table between the action types and the full action objects themselves:

type ActionTable = TagWithKey<"type", ActionPayloadTable>;

This was an easier (albeit way less clear) way of writing:

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

Now we can create convenient names for each of out actions:

type ExampleAction = ActionTable["Example"];
type AnotherAction = ActionTable["Another"];

And we can either create a union by writing

type MyActions = ExampleAction | AnotherAction;

or we can spare ourselves from updating the union each time we add a new action by writing

type Unionize<T> = T[keyof T];

type MyActions = Unionize<ActionTable>;

Finally we can move on to the class you had. Instead of parameterizing on the actions, we'll parameterize on an action table instead.

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

That's probably the part that will make the most sense - Example basically just maps the inputs of your table to its outputs.

In all, here's the code.

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

As of TypeScript 2.8, you can accomplish this via conditional types.

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

NOTE: Credit to @jcalz for the idea of the NarrowAction type from this answer https://stackoverflow.com/a/50125960/20489

This requires a change in TypeScript to work exactly as asked in the question.

If the classes can be grouped as properties of a single object then the accepted answer can help too. I love the Unionize<T> trick in there.

To explain the actual problem, let me narrow down your example to this:

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.

Another thing you could do is pass the actual class to the function. Here's a crazy example:

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

The problem here is you cannot get the value of color. You can RedShape.prototype.color, but this will always be undefined, because the value is only applied in constructor. RedShape is compiled to:

var RedShape = /** @class */ (function () {
    function RedShape() {
    }
    return RedShape;
}());

And even if you do:

class RedShape {
  color: 'Red' = 'Red';
}

That compiles to:

var RedShape = /** @class */ (function () {
    function RedShape() {
        this.color = 'Red';
    }
    return RedShape;
}());

And in your real example constructors might have multiple parameters, etc, so an instantiation might not be possible too. Not to mention it doesn't work for interfaces too.

You might have to revert to silly way like:

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 = ...
*/

Or in your doSomething wording:

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 = ...
*/

As mentioned in a comment on the other answer, there is an issue in the TypeScript for fixing the inference issue already. I wrote a comment linking back to this answer's explanation, and providing a higher level example of the problem here.

Unfortunately, you cannot achieve this behavior using union type (ie 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.

However, your solution is great. You just have to use this way to define the type you need.

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

Although you don't like it, it seems pretty legit way to do what you want.

A little more verbose on the setup but we can achieve your desired API with type lookups:

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);
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!