I have a reducer in react. The action can be one of 8 types, but for simplicity, let's imagine that there's only 2 types
type Add = {
type: 'add';
id: string;
value: string;
}
type Remove = {
type: 'remove';
id: string;
}
type Action = Add | Remove;
Instead of using a switch case, I'm using an object of handlers, where each handler is a function that handles a specific action
const handlers = {
add: (state, action) => state,
remove: (state, action) => state,
default: (state, action) => state,
}
const reducer = (state, action) => {
const handler = handlers[action.type] || handlers.default;
return handler(state, action);
}
Now I want to type the handlers
object appropriately. So the handler function should accept an action of type corresponding to its key in the handlers
object.
type Handlers = {
[key in Action["type"]]: (state: State, action: Action) => State
// ↑this here should be the action which has type
// matching to it's key. So when the key is
// 'add', it should be of type Add, and so on.
}
All I could think of is to explicitly state the key and the matching action type. Is There a way to 'pick' the type from the union, according to the value of the key?
TypeScript Union Type Narrowing To narrow a variable to a specific type, implement a type guard. Use the typeof operator with the variable name and compare it with the type you expect for the variable.
The string literal type allows you to specify a set of possible string values for a variable, only those string values can be assigned to a variable. TypeScript throws a compile-time error if one tries to assign a value to the variable that isn't defined by the string literal type.
Discriminating Unions A common technique for working with unions is to have a single field which uses literal types which you can use to let TypeScript narrow down the possible current type.
TypeScript allows you to define multiple types. The terminology for this is union types and it allows you to define a variable as a string, or a number, or an array, or an object, etc. We can create union types by using the pipe symbol ( | ) between each type.
Extracting just the type is straightforward.
type Add = {
type: 'add';
id: string;
value: string;
}
type Remove = {
type: 'remove';
id: string;
}
type Action = Add | Remove;
type addType = Extract<Action, {type: 'add'}>;
You can create a mapped type to do this automatically for each element of the union.
type OfUnion<T extends {type: string}> = {
[P in T['type']]: Extract<T, {type: P}>
}
This is a mapped type. It's like a type function, it takes a type, transforms it, and returns that new type. So OfUnion
is expecting something that is shaped like T extends {type: string}
. This means it'll accept {type: 'add'}
or a union type like type Action = Add | Remove
. The important thing is every element of that union type has a type: string
property.
When you have a union type and every option has a property in common, you can safely access that property (in this case, type
). This gets distributed, so Action['type']
gives us 'add' | 'remove'
. The mapped type is creating an object that has the keys [P in T['type']]
, a.k.a. 'add' | 'remove'
!
The type of the add
property should be the type of that action, Add
. You can get to this with that Extract<>
call I did. That essentially filters a union to only include elements that match a certain type. In this case, filter to just something that matches {type: 'add'}
. The only option that matches this is the {type: 'add', id: string, value: string}
object, so that's what it's set to.
Now you have an object that looks like this:
{
add: {
type: 'add';
id: string;
value: string;
},
remove: {
type: 'remove';
id: string;
}
}
That's great! But we need to convert that to a handler. So the handler is still going to have add
and remove
keys, but instead of the object types, they will be functions that take the object type and return whatever.
type Handler<T> = {
[P in keyof T]: (variant: T[P]) => any
}
Here an example of P
would be 'add'
and T[P]
would be that Add
type. So now the handler should take in an Add
object, and do something with it. That's exactly what we want.
now you can write a match function that is type-safe w/ autocomplete:
function match<
T extends {type: string},
H extends Handler<OfUnion<T>>,
> (
obj: T,
handler: H,
): ReturnType<H[keyof H]> {
return handler[obj.type as keyof H]?.(obj as any);
}
Note that you need to use extends here to infer the specific type of H. You'll need that fully specified type to get the return types of each branch.
edited playground link
The match()
function needs to take an object, which will have a union type like Action
and then restrict the handler object it's expect to a certain contract (the handler types we created just now). It's better to do this as a function rather than for each case because then you don't need to keep rewriting the type definitions! The function will bring them. It's also better to do this agnostically, without the state object, because you can still use the state object inside of the handler branches. Lookup 'closures' for more info but you can also take it on faith from me that the state
object will be in scope. But now, we can also use match
in places where state
isn't relevant, like in the .jsx.
const reducer = (state: State, action: Action) => match(action, {
add: ({id, value}) => state,
remove: ({id}) => state,
})
I hope that helps! Believe me, using these types is easier than writing them. You need advanced typescript knowledge to write them, but any beginner can use them.
You may be interested in my library variant
, which does this exact thing. In fact these examples are just simplified versions of my library code. My match
function will automatically restrict the handler object you are passing in and the return type will be the union of all the handler branches.
You are welcome to steal this, or, try out the library for yourself. It will include much more for you in this same direction.
You can use the Extract
conditional type to extract a type from a union based on a base type of the desired type.
type Handlers = {
[key in Action["type"]]: (state: State, action: Extract<Action, { type: key }>) => State
}
Playground Link
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