Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 585
jayphelps Avatar asked Sep 20 '17 02:09

jayphelps


People also ask

What is TypeScript narrowing?

TypeScript follows possible paths of execution that our programs can take to analyze the most specific possible type of a value at a given position. It looks at these special checks (called type guards) and assignments, and the process of refining types to more specific types than declared is called narrowing.

What is discriminated unions in TypeScript?

The concept of discriminated unions is how TypeScript differentiates between those objects and does so in a way that scales extremely well, even with larger sets of objects. As such, we had to create a new ANIMAL_TYPE property on both types that holds a single literal value we can use to check against.

Is type guard TypeScript?

A type guard is a TypeScript technique used to get information about the type of a variable, usually within a conditional block. Type guards are regular functions that return a boolean, taking a type and telling TypeScript if it can be narrowed down to something more specific.

What is type checking in TypeScript?

TypeScript type check is used to validate the type of any variable at runtime. Type checking has proven to be a good feature for most JavaScript developers. We will start with the most basic library provided by TypeScript, which will directly validate the type of single variables in the code.


2 Answers

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); 
like image 100
Daniel Rosenwasser Avatar answered Sep 28 '22 09:09

Daniel Rosenwasser


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

like image 40
bingles Avatar answered Sep 28 '22 09:09

bingles