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,
})
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.