When NgRx v8 was released about half a year ago, it included a new factory function to create actions. It comes with a sophisticated type declaration to prevent it from being used in an unintended way. Digging into that type declaration is a bit overwhelming at first but I think the TypeScript tricks that it employes warrant a closer look.
What is NgRx?
In case you're reading this and never heard of NgRx before that shouldn't matter too much. It's a Redux implementation which is very famous amongst Angular developers. But it's also often criticized for the huge amount of boilerplate code it requires. On the other hand most of that boilerplate code is meant to achieve extensive type safety which is a reason why NgRx is so popular in the first place.
As part of the v8 release the NgRx team addressed that problem by providing factory functions which reduce the boilerplate but maintain the type safety. One of those functions was createAction().
What does createAction() do?
As the name implies createAction() is meant to create an action. And an action in the sense of NgRx (or Redux) is just an object with a type property. Everything else is optional. The TypeScript interface of an action looks like this:
interface Action { type: string }
But createAction() doesn't return an action directly. It returns a factory which means it's a function that returns a function. The returned function can then be used to create as many actions as needed.
The signature of createAction() is overloaded many times and its actual implementation is very complex. But when boiled down to the most basic version the implementation would look like this:
function createAction (type) {
return () => ({ type });
}
This version of the createAction() function can now be used to create factory functions for actions of a specific type.
const createClickAction = createAction('click');
The createClickAction() function created above will create a plain object with the type property set to "click" each time it gets invoked.
createClickAction(); // { type: 'click' }
In reality many actions will have other properties (usually abbreviated as props) as well and that's why createAction() also accepts a function as second parameter which is expected to return those props.
That means createAction() is a function which accepts a function and returns a function. When we add that functionality to the simplified version of createAction() introduced above it looks like this:
function createAction (type, createProps) {
return (...args) => ({ ...createProps(...args), type });
}
This allows us to augment the click action with x and y coordinates by providing a function that turns the given parameters (x and y in that case) into the props that are needed to augment the action.
const createClickAction = createAction(
'click',
(x, y) => ({ x, y })
);
createClickAction(2, 3); // { type: 'click', x: 2, y: 3 }
Up to now the implementation of the createAction() function is written in plain JavaScript. The TypeScript version with type annotations looks a bit more complex but in the end it just codifies the intended use of the function that was talked about so far.
The code below basically says that createAction() is a function which expects to be called with two parameters. The first parameter is the type of the action and should be a string. The second parameter is a function that takes any arguments and returns an object. The return type of createAction() is a function which expects to be called with the same arguments as the createProps() function passed as second parameter. It will itself return an object with the type property that gets merged with the object returned by createProps().
function createAction<
T extends string,
U extends any[],
V extends object
>(
type: T, createProps: (...args: U) => V
) {
return (...args: U) => {
return { ...createProps(...args), type };
};
}
This compiles and works fine. But only unless someone comes along and breaks it.
The problem
The createAction() function in its current state looks as if it had a comprehensive type definition. But it falls short if someone overwrites the type property within the createProps() function.
Consider the following example. What is the value returned by createAmbiguousAction()?
const createAmbiguousAction = createAction(
'ambiguous',
(type: number) => ({ type })
);
createAmbiguousAction(12); // ?
TypeScript happily compiles this code but the result doesn't really make sense. It returns
The solution
To prevent createAction() from being used in an unintended way we can use conditional types. That's a way to do conditional branching on the type level. It can be used to define a generic type that can be anything but an object which has a type property. To express that we can use a special primitive type that TypeScript provides to guarantee that something never happens. It's called never.
type NoType<T> = T extends { type: any } ? never : T;
function createAction<
T extends string,
U extends any[],
V extends object
>(
type: T, createProps: (...args: U) => NoType<V>
) {
return (...args: U) => {
return { ...createProps(...args), type };
};
}
The only difference to the previously shown TypeScript code is the newly introduced NoType type. It makes sure that createProps() returns an object but no object with a type property. Consequently, the following code will not compile.
createAction('action', () => ({ type: 20 }));
TypeScript throws the following error:
Type '{type: number;}' is not assignable to type 'never'.
This is expected since we return an object that has a type property which is of type number. When that gets passed to the NoType type it will evaluate to never which will trigger an exception. But in case you are the user of that function and don't know anything about it's internal type definition this error message is not really helpful.
The real solution
Using TypeScript's built-in type never to model the NoType type is very convenient. But it is also possible to use any other type as long as it triggers an internal conflict. That means it has to be unassignable to the expected return type of createProps(). When choosing this type carefully the error message might make more sense.
In the updated code below the type called TypePropertyIsNotAllowed solves that purpose. It is a string literal which is basically a more friendly error message.
type TypePropertyIsNotAllowed =
'type property is not allowed in action creators';
type NoType<T> = T extends { type: any }
? TypePropertyIsNotAllowed
: T;
function createAction<
T extends string,
U extends any[],
V extends object
>(
type: T, createProps: (...args: U) => NoType<V>
) {
return (...args: U) => {
return { ...createProps(...args), type };
};
}
As before the same code is used to trigger an error.
createAction('action', () => ({ type: 20 }));
And, as expected, it still doesn't compile. But this time the error message is giving a clue on what did actually go wrong.
Type '{type: number;}' is not assignable to type
'"type property is not allowed in action creators"'.
It is for sure not a perfect solution but definitely a bit better than the previous message.
I think this technique is especially valuable when used in a codebase with complex type definitions. TypeScript's default errors do not often make sense to the users of such code if they get triggered some levels below the actual function calls. Throwing "custom errors" could really help in such cases to improve the developer experience.
However there is still some room for improvement. Arrays can still be used in an unintended way because every array is also an object in JavaScript. But luckily that can be fixed in a similar fashion. Another edge case is the usage with union types. But that can be solved as well. Unfortunately considering all that also increases the complexity of the type declaration which is why it was omitted here for brevity.
The approach presented here was pioneered by Alex Okrushko and Nicholas Jamieson who both also kindly reviewed this article. It can also be found in the source code of the ts-action project which served as inspiration for the createAction() function in NgRx. Alex did also recently explain the concept in a talk at Angular Toronto.