25

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.

jayphelps
  • 14,317
  • 2
  • 38
  • 52

5 Answers5

33

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);
Daniel Rosenwasser
  • 15,697
  • 7
  • 40
  • 57
  • 4
    For posterity, what I _truly_ want is indeed not currently supported by TS, there's an existing ticket requesting it here: https://github.com/Microsoft/TypeScript/issues/17915 This solution was accepted because it provides the most flexible compromise, though I'm not sure I can (or want to) use it in practice. – jayphelps Sep 20 '17 at 18:18
  • 6
    It was also clever af – jayphelps Sep 20 '17 at 18:18
  • 1
    Any reason why `Unionize` isn't just `T[keyof T]`? – jcalz Sep 25 '17 at 14:58
  • I actually don't know why I didn't just write that @jcalz. I've edited the answer. – Daniel Rosenwasser Sep 25 '17 at 16:29
  • How could I extend this solution to work for an **array** of `keyof T` instead of a single element? Meaning, the function gets an array of possible object type (discriminant) values and it is okay if any one of them is valid in the given object (element of the discriminant union type), an error thrown otherwise. – Mörre Jun 12 '19 at 13:18
  • I found it less complex to wrap objects in a typing layer in the form `{ type: [type string], o: [object]}`. This way you can get the name from the type and visa versa with less complicated logic, which makes debugging errors a whole lot easier. – Geoff Davids Jul 06 '20 at 15:06
13

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

bingles
  • 9,662
  • 6
  • 65
  • 76
  • The key here is, when the typeguard needs to only allow something in form of type `U`, to use a typeguard that itself has a generic input (`T`) **and returns in the following form** `input is T extends U ? T : never`. This will narrow types that include generics - fixed my issues completely! – Geoff Davids Jul 01 '20 at 17:29
3

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.

Meligy
  • 32,897
  • 11
  • 79
  • 103
2

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.

Alex Yatkevich
  • 826
  • 7
  • 10
  • Thanks! I believe that quote doesn't apply when you have a discriminated union that you narrow. https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions But my issue is that TS doesn't current infer the narrowed type, instead requiring I provide it explicitly (as you mention). The reason I'm suspicious this should be possible is that TS appears to only accept the correct type as the generic param--that is, if I pass it anything other than the shape of `ExampleAction` it rejects it. – jayphelps Sep 20 '17 at 03:54
  • e.g. `items.doSomething('Example')` correctly produces: `Argument of type 'Example' is not assignable to parameter of type 'Another'`. My best guess is that it just doesn't currently support inferred narrowing, even if it has the type information to do so. – jayphelps Sep 20 '17 at 03:54
  • Yeah, it seems like the only way to get a discriminated union is to cover the cases in the code right now. Probably, they'll get to support the feature you want one day. I'd stick with more explicit way though. – Alex Yatkevich Sep 20 '17 at 04:06
1

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);
Robert Pitts
  • 78
  • 1
  • 5
  • I believe this is the same as what I mention: "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)" No? – jayphelps Sep 20 '17 at 03:46
  • To clarify a bit, the dev already has to provide a union of all actions, so this would require them to provide that as well as an additional type map. Unless there's a way to create a union from the values of that map? Assuming that's not possible, this would be a tough requirement to swallow for devs--I imagine they'd prefer the explicit generic pass `items.doSomething('Example')` – jayphelps Sep 20 '17 at 04:00
  • Turns out it's sort of possible (with some caveats) to get a union from a type map. See Daniel's crazy answer lololol – jayphelps Sep 20 '17 at 04:27