3 Tricks For Using Redux and Immutable.js with TypeScript
Edit · Jan 18, 2018 · 8 minutes read · Follow @mgechev
In this post, I’ll show you a few tricks which can make your life using Immutable.js and Redux with TypeScript easier. All the practices are inspired by my recent work on the project Rhyme.com.
Immutable Statically Typed Records
Let’s start with the definition of our store. For the purpose I prefer to use Immutable records because of two main reasons:
- Immutable stores make the mutation more explicit and well organized.
- Records allow convenient property access.
Now let’s suppose we have the domain object VirtualMachine
. We can define a well-typed record using the following definition:
export interface IVirtualMachine {
id: string;
state: VMState;
connection: Connection;
screenshot: string;
}
const virtualMachine = RecordFactory<IVirtualMachine>({
id: '',
state: VMState.Pending,
connection: new Connection({}),
screenshot: '',
});
export class VirtualMachine extends virtualMachine implements IVirtualMachine {
id: string;
state: VMState;
connection: Connection;
screenshot: string;
constructor(config: Partial<IVirtualMachine>) {
super(config);
}
}
Notice a few things:
- We define the interface
IVirtualMachine
. - We define a
virtualMachine
record invokingRecordFactory
with its type parameter set toIVirtualMachine
. - We define a class called
VirtualMachine
which extends the record and accepts a single property of typePartial<IVirtualMachine>
in its constructor.
Notice that we export IVirtualMachine
and VirtualMachine
but keep the virtualMachine
record encapsulated inside the current module.
This way we can already instantiate a new VirtualMachine
object and configure it using an object literal:
const vm = new VirtualMachine({ id: '123', state: VMState.Running });
Since the config
parameter of the constructor is of type Partial<IVirtualMachine>
we do not have to set all the properties of the IVirtualMachine
interface. Of course, the drawback of this approach is that by mistake we can skip a configuration property and get the default value instead, which might not be the intended behavior. In this specific case, I prefer convenience over security.
In order to get a better idea of why the definition above is safer than a regular record, let’s take a look at the RecordFactory
function:
interface Constructable<T> {
new (...args: any[]): T;
}
interface StaticallyTypedRecord<T> extends Constructable<T> {
get<K extends keyof T>(key: K): T[K];
set<K extends keyof T, V extends T[K]>(key: K, value: V);
withMutations(cb: (r: StaticallyTypedRecord<T>) => StaticallyTypedRecord<T>);
setIn<K1 extends keyof T, V extends T[K1]>(keys: [K1], val: V);
setIn<K1 extends keyof T, K2 extends keyof T[K1], V extends T[K1][K2]>(keys: [K1, K2], val: V);
setIn<K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], V extends T[K1][K2][K3]>(
keys: [K1, K2, K3],
val: V
);
toJS(): T;
}
export const RecordFactory = <T>(seed: T): StaticallyTypedRecord<T> => {
return (Record(seed) as any) as StaticallyTypedRecord<T>;
};
Here we have three definitions:
Constructable<T>
is a generic interface which defines a type for a family of objects which can be instantiated with the operatornew
.StaticallyTypedRecord<T>
is another generic interface which extends theConstructable<T>
interface and declares a bunch of methods. These are all methods in the prototype of theImmutable.Record
abstraction.RecordFactory
is a generic function which accepts an object calledseed
of typeT
, passes it toImmutable.Record(seed)
and ascribes the result to the typeStaticallyTypedRecord<T>
.
What’s the win here?
Let’s suppose we have our VM:
const vm = new VirtualMachine({ id: '123', screenshot: '...' });
Now if we want to access the VM properties we can:
vm.id;
vm.get('id');
// [ts] Argument of type '"ID"' is not assignable to
// parameter of type '"id" | "state" | "connection" | "screenshot" | "templateId"'.
vm.get('ID');
// [ts] Property 'ID' does not exist on type 'VirtualMachine'. Did you mean 'id'?
vm.ID;
Notice how we got a compile-time error when we tried to access the id
property with vm.get('ID')
since such key doesn’t exist in the record.
We are also going to get compile-time errors when trying to set a missing property:
// [ts] Argument of type '"scrnshot"' is not assignable to
// parameter of type '"id" | "state" | "connection" | "screenshot" | "templateId"'.
vm.set('scrnshot');
This way we’re able to catch mistakes caused by a misspelling of a property name much sooner - at compile-time!
Even further, now let’s suppose the Connection
abstraction has an ipAddress
field of type string
. If we want to set it using the setIn
method of the VirtualMachine
record and we misspell it we’re going to get a compile-time error again!
// [ts]
// Argument of type '["connection", "ipaddress"]' is not assignable to parameter of type '["connection", "id" | "connectionId" | "type" | "get" | "set" | "withMu...'.
// Types of property '1' are incompatible.
// Type '"ipaddress"' is not assignable to type '"id" | "connectionId" | "ipAddress" | "get" | "set" | "withMutations" | "set...'.
vm.setIn(['connection', 'ipaddress'], '192.168.0.102');
Notice that currently, we support no more than three levels of nesting with setIn
, this is due to the explicit definition above. A random level of nesting will be possible once the variadic types proposal gets introduced into TypeScript.
Action Creators
Alright, we now have a solid foundation for our store. Now, we can define the actions in the application. First, we can define an enum which contains all the action types:
enum ActionType {
AddVirtualMachine = 'AddVM',
RemoveVirtualMachine = 'RemoveVM'
}
As next step, we can define an interface for each action:
interface AddVirtualMachine {
type: ActionType.AddVirtualMachine;
vm: VirtualMachine;
}
interface RemoveVirtualMachine {
type: ActionType.RemoveVirtualMachine;
id: string;
}
type Action = AddVirtualMachine | RemoveVirtualMachine;
Notice that in the end we also define the type Action
as the intersection between the AddVirtualMachine
and RemoveVirtualMachine
. This allows us to have very convenient support from the type checker in our reducers:
const virtualMachinesReducer = (action: Action, state: List<VirtualMachine> = initialState) {
switch (a.type) {
case ActionType.AddVirtualMachine:
return state.push(action.vm);
case ActionType.RemoveVirtualMachine:
return state.delete(state.findIndex((v: VirtualMachine) => v.id === action.id));
break;
}
}
In this case, the type checker will know exactly what the type of the action
argument will be in any of the branches of the switch
statement, so we will get a great type checking support and auto-completion.
Interface vs Factory
A typical pattern in redux is to use factories for creating the action objects. Another typical pattern in the Angular community (more specifically, this part of it using ngrx
) is to use classes, for example:
export class AddVirtualMachine {
readonly type = ActionType.AddVirtualMachine;
constructor(public vm: VirtualMachine) {}
}
export class RemoveVirtualMachine {
readonly type = ActionType.RemoveVirtualMachine;
constructor(public id: string) {}
}
When using classes we get few benefits:
- Read-only action type because of the
readonly
modified in the property declaration. - Convenient static typing when passing the arguments to the constructor.
Let’s compare two syntaxes for dispatching actions - one using classes and another just object literals:
dispatch(new RemoveVirtualMachine(vm.id))
dispatch({ type: ActionType.RemoveVirtualMachine, id: vm.id })
We can clearly see that the second option is a bit longer and also couples the component with the action type. On the other hand, new need to introduce an additional middleware in redux because by default it accepts only “plain” objects:
const isPlainObject = require('lodash.isplainobject');
// Allows us have the privilege to create actions
// with classes instead of action creators.
export const plainObjectMiddleware = store => next => (a: any) => {
if (!isPlainObject(a)) {
const result: any = {};
// We don't need hasOwnProperty and we know what we are doing.
// tslint:disable
for (let prop in a) {
result[prop] = a[prop];
}
// tslint:enable
a = result;
}
return next(a);
};
Improved Middleware Declaration
By default the TypeScript’s type definitions for redux come with the following middleware interface:
export interface Middleware {
<S>(api: MiddlewareAPI<S>): (next: Dispatch<S>) => Dispatch<S>;
}
This way when we want to declare a middleware, let’s say:
import { Middleware } from 'redux';
export const sampleMiddleware = (): Middleware => {
return ({ getState }) => next => action => {
const state = getState();
return next(a);
};
};
This snippet has few problems:
getState
will have typeS
which in this case is completely meaningless to us.next
has typeDispatch<S>
, completely meaningless as well.action
has typeany
, which doesn’t bring any extra semantics.
To have a well-typed middleware we can apply the following definition:
export const sampleMiddleware = (): Middleware => {
return ({ getState }: MiddlewareAPI<IStore>) => next => <A extends Action>(action: A) => {
const state = getState();
console.log(state);
return next(a);
};
};
This brings several improvements:
getState
is well-typed because ofMiddlewareAPI<IStore>
type.next
has now typeDispatch<IStore>
, thanks to theMiddlewareAPI<IStore>
annotation.action
now brings some extra semantics, for instance, we can access itstype
property and in case of a conditional statement, we can use the provided by TypeScript control flow type inference.
Note that, depending on the version of the redux type definitions you use, you may need to take advantage of the open interfaces of TypeScript and improve the Middleware
declaration:
import { MiddlewareAPI } from 'redux';
declare module 'redux' {
export interface Middleware<T = any> {
<U>(api: MiddlewareAPI<T>): (next: Dispatch<T>) => Dispatch<T>;
}
}
Otherwise, you may get the error:
[ts]
Type '<S>({ getState }: MiddlewareAPI<IStore>) => (next: Dispatch<S>) => <A extends Action>(action: A) ...' is not assignable to type 'Middleware'.
Types of parameters '__0' and 'api' are incompatible.
Type 'MiddlewareAPI<S>' is not assignable to type 'MiddlewareAPI<IStore>'.
Type 'S' is not assignable to type 'IStore'.
Conclusion
Redux provides a lovely architectural pattern which allows us to isolate the side-effects and keep most of our codebase pure. This way, the state management of our application gets much more predictable.
On top of this, using a statically typed language which fails early when the compiler finds a defect in our program brings significant improvements in our development process.
Combining Redux and TypeScript is a great middle ground between purity and practicality.