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

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

Leave a Comment