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?
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.
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With