Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In Typescript, how to select a type from a union, using a literal type property of said type?

Tags:

typescript

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?

like image 640
Ahmad Mayo Avatar asked Oct 25 '20 18:10

Ahmad Mayo


People also ask

How do you handle a union type in TypeScript?

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.

What is literal type in TypeScript?

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.

What is the name of the technique where you use a single field of literal types to let TypeScript narrow down the possible current 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.

How do I assign two TypeScript types?

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.


Video Answer


2 Answers

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.

like image 197
Paarth Avatar answered Oct 26 '22 23:10

Paarth


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

like image 42
Titian Cernicova-Dragomir Avatar answered Oct 27 '22 00:10

Titian Cernicova-Dragomir