typescripttypescript-typingsdiscriminated-union

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


I have a class method which accepts a single argument as a string and returns an object which has the matching type property. This method is used to narrow a discriminated union type down, and guarantees that the returned object will always be of the particular narrowed type which has the provided type discriminate value.

I'm trying to provide a type signature for this method that will correctly narrow the type down from a generic param, but nothing I try narrows it down from the discriminated union without the user explicitly providing the type it should be narrowed down to. That works, but is annoying and feels quite redundant.

Hopefully this minimum reproduction makes it clear:

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

I also tried getting clever, attempting to build up a "mapped type" dynamically--which is a fairly new feature in TS.

declare class Example2<T extends Action> {
  doSomething<R extends T['type'], TypeMap extends { [K in T['type']]: T }>(key: R): TypeMap[R];
}

This suffers from the same outcome: it doesn't narrow the type because in the type map { [K in T['type']]: T } the value for each computed property, T, is not for each property of the K in iteration but is instead just the same MyActions union. If I require the user provide a predefined mapped type I can use, that would work but this is not an option as in practice it would be a very poor developer experience. (the unions are huge)


This use case might seem weird. I tried to distill my issue into a more consumable form, but my use case is actually regarding Observables. If you're familiar with them, I'm trying to more accurately type the ofType operator provided by redux-observable. It is basically a shorthand for a filter() on the type property.

This is actually super similar to how Observable#filter and Array#filter also narrow the types, but TS seems to figure that out because the predicate callbacks have the value is S return value. It's not clear how I could adapt something similar here.


Solution

  • 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