Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typecheck createAction Redux helper

I have Action type defined like this:

type Action =
    { type: 'DO_X' }
    |
    { type: 'DO_Y', payload: string }
    |
    { type: 'DO_Z', payload: number }

It's a union type where each member is a valid action.

Now I'd like to create a function createAction that accepts type and returns a new function that accepts payload.

const doZ = createAction('DO_Z')
console.log(doZ(42)) // { type: 'DO_Z', payload: 42 }

Here's my current implementation:

const createAction = (type: Action['type']) =>
  (payload?: any) =>
    ({ type, payload })

It typechecks type like I want to. How can I also typecheck payload? I want payload to match type of correct action based on type. For example, doZ should fail when called with a string because it's payload says that it accepts only number.

like image 537
daGrevis Avatar asked Aug 02 '17 15:08

daGrevis


2 Answers

The canonical answer to this question depends on your exact use case. I'm going to assume that you need Action to evaluate exactly to the type you wrote; that is, an object of type: "DO_X" does not have a payload property of any kind. This implies that createAction("DO_X") should be a function of zero arguments, while createAction("DO_Y") should be a function of a single string argument. I'm also going to assume that you want any type parameters on createAction() to be automatically inferred, so that you don't, for example, need to specify createAction<Blah>("DO_Z") for any value of Blah. If either of these restrictions is lifted, you can simplify the solution to something like the one given by @Arnavion.


TypeScript doesn't like mapping types from property values, but it's happy to do so from property keys. So let's build the Action type in a way that provides us with types the compiler can use to help us. First we describe the payloads for each action type like this:

type ActionPayloads = {
  DO_Y: string;
  DO_Z: number;
}

Let's also introduce any Action types without a payload:

type PayloadlessActionTypes = "DO_X" | "DO_W";

(I've added a 'DO_W' type just to show how it works, but you can remove it).

Now we're finally able to express Action:

type ActionMap = {[K in keyof ActionPayloads]: { type: K; payload: ActionPayloads[K] }} & {[K in PayloadlessActionTypes]: { type: K }};
type Action = ActionMap[keyof ActionMap];

The ActionMap type is an object whose keys are the type of each Action, and whose values are the corresponding elements of the Action union. It is the intersection of the Actions with payloads, and the Action without payloads. And Action is just the value type of ActionMap. Verify that Action is what you expect.

We can use ActionMap to help us with typing the createAction() function. Here it is:

function createAction<T extends PayloadlessActionTypes>(type: T): () => ActionMap[T];
function createAction<T extends keyof ActionPayloads>(type: T): (payload: ActionPayloads[T]) => ActionMap[T];
function createAction(type: string) {
  return (payload?: any) => (typeof payload === 'undefined' ? { type } : { type, payload });
}

It's an overloaded function with a type parameter T corresponding to the type of Action you are creating. The top two declarations describe the two cases: If T is the type of an Action with no payload, the return type is a zero-argument function returning the right type of Action. Otherwise, it's a one-argument function that takes the right type of payload and returns the right type of Action. The implementation (the third signature and body) is similar to yours, except that it doesn't add payload to the result if there is no payload passed in.


All done! We can see that it works as desired:

var x = createAction("DO_X")(); // x: { type: "DO_X"; }
var y = createAction("DO_Y")("foo"); // y: { type: "DO_Y"; payload: string; }
var z = createAction("DO_Z")(5); // z: { type: "DO_Z"; payload: number; }

createAction("DO_X")('foo'); // too many arguments
createAction("DO_X")(undefined); // still too many arguments
createAction("DO_Y")(5); // 5 is not a string
createAction("DO_Z")(); // too few arguments
createAction("DO_Z")(5, 5); // too many arguments

You can see all this in action on the TypeScript Playground. Hope it works for you. Good luck!

like image 171
jcalz Avatar answered Jan 03 '23 13:01

jcalz


Verbose but this works:

type XAction = { type: 'DO_X', payload: undefined }; 
type YAction = { type: 'DO_Y', payload: string }; 
type ZAction = { type: 'DO_Z', payload: number }; 

type Action = XAction | YAction | ZAction;

const createAction = <T extends Action>(type: T['type']) =>
    (payload: T['payload']) =>
        ({ type, payload });

// Do compile:

createAction<XAction>("DO_X")(undefined);
createAction<YAction>("DO_Y")("foo");
createAction<ZAction>("DO_Z")(5);

// Don't compile:

createAction<XAction>("DO_X")(5); // Expected `undefined`, got number
createAction<YAction>("DO_Y")(5); // Expected string, got number
createAction<ZAction>("DO_X")(5); // Expected `"DO_Z"`, got `"DO_X"`

The easier way (not forcing the type parameter of createAction):

type Action = { type: 'DO_X', payload: undefined } | { type: 'DO_Y', payload: string } | { type: 'DO_Z', payload: number };

createAction("DO_Y")("foo");

unfortunately allows createAction<YAction>("DO_Y")(5) etc to compile, since T is always inferred as Action and thus the payload parameter is string|number|undefined

like image 30
Arnavion Avatar answered Jan 03 '23 12:01

Arnavion