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
.
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 Action
s with payload
s, and the Action
without payload
s. 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!
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
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