When using redux in JS your typical implementation often looks like this:
export const SUBMIT_NAME = 'SUBMIT_NAME';
export const SUBMIT_AGE = 'SUBMIT_AGE';
export const SUBMIT_HEIGHT = 'SUBMIT_HEIGHT';
export const submitName = name => ({
type: SUBMIT_NAME,
name
});
export const submitAge = age => ({
type: SUBMIT_AGE,
age
});
export const submitHeight = height => ({
type: SUBMIT_HEIGHT,
height
});
The reducer can look like this:
import { SUBMIT_NAME, SUBMIT_AGE, SUBMIT_HEIGHT } from '../actions';
const initialState = {
name: '',
age: '',
height: ''
};
export default (state = initialState, action) => {
switch (action.type) {
case SUBMIT_NAME:
return {
...state,
name: action.name
};
case SUBMIT_AGE:
return {
...state,
age: action.age
};
case SUBMIT_HEIGHT:
return {
...state,
height: action.height
};
default:
return state;
}
};
Now imagine that you want to introduce a new action, but forget to change your reducer. In small applications this is rarely an issue, but as complexity grows faults like these can often sneak in. The reverse example is also relevant. An action is removed since it's no longer needed, but you're unsure if it can be removed from the reducer. This is where Typescript comes to the rescue.
Actions look like this:
export enum TypeKeys {
SUBMIT_NAME = 'SUBMIT_NAME',
SUBMIT_AGE = 'SUBMIT_AGE',
SUBMIT_HEIGHT = 'SUBMIT_HEIGHT'
}
export interface SubmitName {
type: TypeKeys.SUBMIT_NAME;
name: string;
}
export interface SubmitAge {
type: TypeKeys.SUBMIT_AGE;
age: number;
}
export interface SubmitHeight {
type: TypeKeys.SUBMIT_HEIGHT;
height: number;
}
export type ActionTypes = SubmitAge | SubmitName | SubmitHeight;
export const submitName = (name: string): SubmitName => ({
type: TypeKeys.SUBMIT_NAME,
name
});
export const submitAge = (age: number): SubmitAge => ({
type: TypeKeys.SUBMIT_AGE,
age
});
export const submitHeight = (name: number): SubmitHeight => ({
type: TypeKeys.SUBMIT_HEIGHT,
height
});
Here we define three new interfaces for our actions. The main upside so far it that the code is more explicit and we have a concrete definition of every action, however the main benefit becomes clear when we change our reducer to Typescript.
import { ActionTypes, TypeKeys } from './_actions';
const initialState: State = {
name: ''
};
interface State {
readonly name: string;
readonly age?: number;
readonly height?: number;
}
function assertNever(x: never): never {
return x;
}
export default (state: State = initialState, action: ActionTypes): State => {
switch (action.type) {
case TypeKeys.SUBMIT_NAME:
return {
...state,
name: action.name
};
case TypeKeys.SUBMIT_AGE:
return {
...state,
age: action.age
};
case TypeKeys.SUBMIT_HEIGHT:
return {
...state,
height: action.height
};
default:
assertNever(action);
return state;
}
};
Say we now introduce a new action, registerPerson, like this:
export enum TypeKeys {
SUBMIT_NAME = 'SUBMIT_NAME',
SUBMIT_AGE = 'SUBMIT_AGE',
SUBMIT_HEIGHT = 'SUBMIT_HEIGHT',
REGISTER_PERSON: 'REGISTER_PERSON'
}
export interface RegisterPerson {
type: TypeKeys.REGISTER_PERSON
}
export const registerPerson = (): RegisterPerson => ({
type: TypeKeys.REGISTER_PERSON
});
export type ActionTypes = SubmitAge | SubmitName | SubmitHeight | RegisterPerson;
If we forget to update our reducer Typescript will automatically complain, because of the assertNever(action)
.
[ts] Argument of type 'RegisterPerson' is not assignable to parameter of type 'never'.
One other benefit of introducing Typescript to Redux is that we now only have access to the properties defined in our interface in each switch case. Typescript (and the IDE, if configured correctly) is smart enough to understand which property belongs to which action type, hence the compiler can validate that only the ‘age’ property within the switch case SUBMIT_AGE
is allowed.