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!
<Type, Payload>(type: Type) : (payload: Payload) => {type: Type, payload: Payload}
? – SchadenfreudeType
andPayload
is coming fromAction
? – Harty