Actions
26th Jun 2017
Hello again, another monday another chance to explore the Power of TypeScript. We're taking a journey through ways TypeScript can help create robust and scalable apps. At Seccl we love using TypeScript and I've been using it for several years now. Adopting it has been one of the best decisions I have made.
This series:
Actions
Let's talk about actions, action creators and how reducers interact with them. Actions are a really helpful way to create separation between the different layers of an application, because they are not tightly coupled each layer can be built and tested in pieces. It does bring it's challenges however, whilst the code is loosely coupled, all areas of the code which interact with the action need to have the same expectation of it's structure.
At Seccl all our actions follow the same basic structure and TypeScript allows us to define what that is by using an interface
. Here is the IGenericAction
interface that we use currently.
// We have an action with customisable payload and metaexport interface IGenericAction<P, M> {// Our type is a const stringtype: string;// The payload can be anything but will be// specified for a particular action within// the apppayload?: P;// The meta property can be used to send// further information along with the action// often used by redux middlewaremeta?: M;// This is a flag to tell if the payload// is an errorerror?: boolean;}
This generic action is usually needed in one of three flavours so we also define these to make the code more readable. We'll look at some concrete examples of each below but first let's quickly look at their interface definitions.
A positive action
The first flavour is the most obvious. A regular action which holds some sort of data in it's payload.
export interface IAction<P> extends IGenericAction<P, undefined> {}
Here we are creating a shorthand to defining an action where the payload is unknown and the meta is undefined. You will see the angled brackets a lot in TypeScript. In this format it is called Generics where we are able to define what we know of an object and allow the rest to be defined elsewhere. Here the payload is generic and could be anything.
An Error
The next is an error action. All our errors conform to the IError
interface so in this case the payload will be an IError
and the meta can be specified if needed.
export interface IErrorAction<M> extends IGenericAction<IError, M> {}
All the errors in our app are passed around in a common structure. This allows us to let them flow through many layers of our application and be used for different purposes or be displayed in different ways depending on what is most appropriate.
An unknown action
The last flavour is the any flavour, to be used when we don't know what we got! Often seen in slice reducers where all actions get passed through.
export interface IAnyAction extends IGenericAction<any, any> {}
Sometimes, the action we are passed could be any of the above so we've defined this action as having any
payload and any
meta (<any, any>
). We'll see these any actions used in the next section.
In the wild
So now that we know the flavours, let's look at an example of our action interface in the wild. Our application requires the user to log in with their username and password. What we will create is an interface which describes the payload
called IAuthenticateCredentials
and an action creator which builds the action.
export interface IAuthenticateCredentials {username: string;password: string;}
Our action creator takes username
and password
parameters and builds an action with them in the payload. The resulting action type should be IAction<IAuthenticateCredentials>
.
This means that the payload must match the definition of IAuthenticateCredentials
. If it doesn't TypeScript will let you know so that you can fix it.
We'll tighten this up a little further by using a type
which can be shared between the action creator and the reducer.
type AuthenticateCredentialsAction = IAction<IAuthenticateCredentials>;
And then here is what the action creator will look like.
export const authenticateCredentials =(username: string, password: string): AuthenticateCredentialsAction => ({type: AUTHENTICATE_CREDENTIALS,payload: {username,password}});
So we have our action creator, we also need to corresponding reducer to do something with that action. Our top level authenticateReducer
could be passed any type of action. Our expectation is that all actions will go through each top level reducer. Here we can use our IAnyAction
as a shortcut for writing IGenericAction<any, any>
.
You can see that we also have a case function which is only used when the action matches our AUTHENTICATE_CREDENTIALS
constant. We know that the action type will be AuthenticateCredentialsAction
at this point and so we can specify that as the action
parameter type.
export interface IAuthenticationState {authenticating: boolean;}export function authenticateReducer(state: IAuthenticationState, action: IAnyAction) {switch (action.type) {case AUTHENTICATE_CREDENTIALS: return credentials(state, action);default: return state;}}export function credentials(state: IAuthenticationState, action: AuthenticateCredentialsAction) {const {payload} = action;return {...state,authenticating: payload.username};}
Almost there! Let's just dig a little deeper to finish off today.
Following the flow of types
Let's look closer at this line within authenticateReducer
.
case AUTHENTICATE_CREDENTIALS: return credentials(state, action);
TypeScript knows that action
is an IAnyAction
but it allows us to pass it to the credentials
function which is expecting an AuthenticateCredentialsAction
. Let's compare these two interfaces as pure objects and hopefully we'll see why.
-
The
IAnyAction
definition looks like this:interface {type: string;payload: any;meta: any;error?: boolean;} -
AuthenticateCredentialsAction
is atype
alias ofIAction<IAuthenticateCredentials>
. -
IAction<IAuthenticateCredentials>
definition looks like this:interface {type: string;payload: IAuthenticateCredentials;meta: undefined;error?: boolean;}
Follow these steps and you'll see how these are compatible:
- the only differences are the
payload
and themeta
properties. any
type is compatible with any type- so
any -> undefined
is compatible - so
any -> IAuthenticateCredentials
is compatible
This is a simplified way of how TypeScript looks at these two definitions.
We've looked through Generics and Type Aliases
We've covered quite a lot in this instalment. We've looked at Generics – a way to allow customisation on top of known properties – and Type Aliases – allowing us to define a type to be shared between areas of our code. Hopefully you are getting the picture that all these contracts between pieces of code will allow TypeScript to keep builds robust and scale well. Join me next Monday when I'll be looking at reducers.