Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to discriminate a discriminated union type

Tags:

typescript

Let's say I've got a discriminated union type to represent Redux actions:

interface AddTodoAction { type: 'ADD_TODO'; description: string; }
interface RemoveTodoAction { type: 'REMOVE_TODO'; id: number; }
type Action = AddTodoAction | RemoveTodoAction;

If I wanted to make a map of action types to reducers that handle them, I might start with:

type ActionReducers = {
  [P in Action['type']]: (state: State, action: Action) => State
};

However, the second argument (action: Action) is too general. I'd like to say "the Action with type corresponding to P", but I don't know if it exists. I tried Action & {type: P} but that sort of does the opposite.

Any ideas?

like image 231
sjmeverett Avatar asked Feb 01 '17 00:02

sjmeverett


1 Answers

UPDATE, JULY 2018

Since I wrote this answer, TypeScript 2.8 introduced conditional types, which makes this possible.

For example, in this case:

type DiscriminateAction<T extends Action['type']> = Extract<Action, {type: T}>

where Extract<T, U> is a conditional type from the standard library defined as:

type Extract<T, U> = T extends U ? T : never;

which uses the distributive property of conditional types to split up the union T and pull out only those parts which match U.

Here's how ActionReducers would be defined:

type ActionReducers = {
  [P in Action['type']]: (state: State, action: DiscriminateAction<P>) => State
};

So, that works! Hope that helps people.


ORIGINAL ANSWER, JULY 2017

TypeScript doesn't let you look up the type of a tagged union automatically. It's a neat idea, so you might want to make a suggestion. The logic is already implemented as part of control flow analysis; maybe it could be exposed as a type operator of some sort.


In the absence of this feature, there are workarounds. The most straightforward way is just to declare the reverse mapping yourself and then refer to it whenever you need it, at the expense of some repetition:

type ActionMapping = {
  ADD_TODO: AddTodoAction;
  REMOVE_TODO: RemoveTodoAction;
}
interface Action { type: keyof ActionMapping }; // useful for consistency
interface AddTodoAction extends Action {
  type: 'ADD_TODO'; // manually cross-reference
  description: string;
}
interface RemoveTodoAction extends Action {
  type: 'REMOVE_TODO'; // manually cross-reference
  id: number;
}
// if you want more Action types you need to add it to ActionMapping:
interface BadAction extends Action {
  type: 'BAD'; // error, BadAction incorrectly extends Action
  title: string;
}

Now you can define what you want as:

type ActionReducers = {
  [P in keyof ActionMapping]: (state: State, action: ActionMapping[P]) => State
};

Here's another way with less duplication, but which is more convoluted:

// define all your data types here without the type property
type ActionDataMapping = {
  ADD_TODO: { description: string },
  REMOVE_TODO: { id: number }
}

// the ActionMapping programmatically adds the right tag to the data  
type ActionMapping = {
  [K in keyof ActionDataMapping]: ActionDataMapping[K] & { type: K };
}

// and an Action is just the union of values of ActionMapping properties    
type Action = ActionMapping[keyof ActionMapping];

// this is the same as above
type ActionReducers = {
  [P in keyof ActionMapping]: (state: State, action: ActionMapping[P]) => State
};

Everything should work here too. You lack nice names for your Action subtypes. Add them back if you want, but it's a little more duplication:

// if you need names for them:
type AddTodoAction = ActionMapping['ADD_TODO'];
type RemoveTodoAction = ActionMapping['REMOVE_TODO'];

Hope one of those works for you. Good luck!

like image 81
jcalz Avatar answered Sep 30 '22 05:09

jcalz