Flexible functional API that's specifically designed to reduce types verbosity (especially maintainability concerns) and complexity (thanks to powerful helpers).
Found it usefull? Want more updates? Show your support by giving a โญ
This lib is an integral part of React & Redux TypeScript Guide ๐
Reference implementation of Todo-App with
typesafe-actions
: https://codesandbox.io/s/github/piotrwitek/typesafe-actions-todo-app ๐ป
Now compatible with TypeScript v2.8.3 (rewritten using conditional types) ๐
- small and focused - according to
rollup-plugin-filesize
(Bundle size: 2.6 KB, Gzipped size: 808 B) check also on bundlephobia - secure and optimized - no external dependencies with 3 different bundle types (
cjs
,esm
andumd
for browser) - solid as a rock - complete test-suite for entire API surface with extra tests for static-types
When I was first starting with Redux and TypeScript I was trying to use redux-actions to simplify maintainability of action-creators. I was struggling and results were intimidating: incorrect type signatures and broken type-inference cascading throughout the entire code-base (read more detailed comparison).
Moreover alternative solutions in the wild have been either too verbose because of excess type annotations (primary maintainability concern) or used classes (hinders readability and enforce to use a new keyword ๐ฑ).
The solution for all the above pain points is finally here, the typesafe-actions
.
The core idea was to design and API that would harness the power of incredible type-inference ๐ช to lift the "maintainability burden" of type annotations. In addition I wanted to make it "look and feel" as close as possible to idiomatic JavaScript we all know and love โค๏ธ, maybe sometimes we even hate but anyway...
To showcase flexibility and the power of type-safety provided by this library, lets build together the common parts of a typical todo-app following Redux architecture:
WARNING: Please make sure that you understand the following concepts of programming languages to be able to follow along with me: Type Inference, Control flow analysis, Tagged union types, Generics and some Advanced Types.
Different projects have different needs and conventions vary across teams this is why
typesafe-actions
was designed and build with flexibility in mind and it provides 3 different factory functions, so you can choose what would be the best fit for your project.
Using this simple function we'll have complete type-safety with minimal type declaration effort, but we're constrained to use constants (as in regular JS applications) because some of advanced action-helpers (getType
, isActionOf
) will not work with such action-creator. This is still very compelling option especially for refactoring existing projects.
import { action } from 'typesafe-actions';
import { ADD, TOGGLE } from './constants';
export const toggle = (id: string) => action(TOGGLE, id);
// (id: string) => { type: 'todos/TOGGLE'; payload: string; }
export const add = (title: string) => action(ADD, { title, id: cuid(), completed: false } as Todo);
// (title: string) => { type: 'todos/ADD'; payload: Todo; }
This approach will give us access for all the action-helpers and eliminate to use constants in the application, but it's opinionated and will always accept 2 arguments (1st is payload, and 2nd is meta). It also have very usefull map
chain method for an extra flexibility.
import { createStandardAction } from 'typesafe-actions';
export const toggle = createStandardAction('todos/TOGGLE')<string>();
// (payload: string) => { type: 'todos/TOGGLE'; payload: string; }
export const add = createStandardAction(ADD).map(
({ title }: { title: string }) => ({
payload: { title, id: cuid(), completed: false } as Todo,
})
);
// ({ title: string }) => { type: 'todos/ADD'; payload: Todo; }
This approach will gives us the best of both worlds: all the action-helpers will work and we have the flexibility of providing variadic amount of named parameters like with regular functions.
import { createAction } from 'typesafe-actions';
export const toggle = createAction('todos/TOGGLE', resolve => {
return (id: string) => resolve(id);
});
// (id: string) => { type: 'todos/TOGGLE'; payload: string; }
export const add = createAction('todos/ADD', resolve => {
return (title: string) => resolve({ title, id: cuid(), completed: false } as Todo);
});
// (title: string) => { type: 'todos/ADD'; payload: Todo; }
For more examples check the API Docs, there are plenty!
Here we'll start by generating an tagged union type of actions (TodosAction). It's very easy to do using TS type-inference and ActionType
utility-type provided by typesafe-actions
.
import { ActionType, getType } from 'typesafe-actions';
import * as todos from './actions';
export type TodosAction = ActionType<typeof todos>;
Now we define a regular reducer function by annotating state
and action
arguments with their respective types (use TodosAction
for action type)
export default (state: Todo[] = [], action: TodosAction) => {
With static types in place we can finally leverage tagged union types. Using switch-cases on common type
property of action we can distinguish and narrow union type of TodosAction
to a single specific action type valid for the corresponding code block.
switch (action.type) {
case getType(todos.add):
// below action type is narrowed to: { type: "todos/ADD"; payload: Todo; }
return [...state, action.payload];
...
Notice we are using getType
action-helper with respective action-creator as an argument to handle corresponding switch case. This will help to reduce boilerplate and completely remove the need of using type-constants in our application.
Note: If your team prefer to use regular "type constants", no problem. You can still use them with
typesafe-actions
.
PRO-TIP: I can recommend to create a
RootAction
in the cenral point of your redux store - it will model a complete representation of all possible action types in your application, you can even merge it with existing third-party declarations like it's shown below.
// types.d.ts
// example of including `react-router` actions in `RootAction`
import { RouterAction, LocationChangeAction } from 'react-router-redux';
type ReactRouterAction = RouterAction | LocationChangeAction;
import { TodosAction } from '../features/todos';
export type RootAction =
| ReactRouterAction
| TodosAction;
Starring
redux-observable
epics
To handle async-flow of request to a remote resource in our application, we'll implement an epic
that'll call a remote API using injected todosApi
client returning a Promise.
To help us simplify the creation process of necessary action-creators we'll use createAsyncAction
function providing us with a nice common interface { request: ... , success: ... , failure: ... }
that will nicely fit with the functional API of RxJS
.
This will mitigate redux verbosity and greatly reduce the maintenance cost of type annotations for actions objects and action-creators, that otherwise we would have to write by hand.
// actions.ts
import { createAsyncAction } from 'typesafe-actions';
const fetchTodos = createAsyncAction(
'FETCH_TODOS_REQUEST',
'FETCH_TODOS_SUCCESS',
'FETCH_TODOS_FAILURE'
)<void, Todo[], Error>();
// epics.ts
import { fetchTodos } from './actions';
const fetchTodosFlow: Epic<RootAction, RootState, Services> = (action$, store, { todosApi }) =>
action$.pipe(
filter(isActionOf(fetchTodos.request)),
switchMap(action =>
from(todosApi.getAll(...)).pipe(
map(fetchTodos.success),
catchError(pipe(fetchTodos.failure, of))
)
);
Starring
redux-observable
epics
To showcase handling of various side-effects in our application we'll implement an epic
responsible of showing a notification when the user adds a new todo.
In the async-flow section above we have already seen the usage of isActionOf
function. We used it to help us filter actions coming from the source stream using action-creators as an argument. It's also a type-guard so it'll narrow tagged union type of all actions (here we're using RootAction
) to a specific action down the pipe in epics.
// epics.ts
import { isActionOf } from 'typesafe-actions';
import { add, toggle } from './actions';
const addTodoToast: Epic<RootAction, RootState, Services> = (action$, store, { toastService }) =>
action$.pipe(
filter(isActionOf(add)),
tap(action => { // here action type is narrowed to: { type: "todos/ADD"; payload: Todo; }
toastService.success(...);
})
...
PRO-TIP: It also works with multiple actions as an array argument
action$.pipe(
filter(isActionOf([add, toggle])) // here action type is narrowed to a smaller union:
// { type: "todos/ADD"; payload: Todo; } | { type: "todos/TOGGLE"; payload: string; }
ALTERNATIVE: But if your team prefer to use type-constants what then? I still got you covered! We have equivalent isOfType
function that will work with type-constants instead of action-creators.
// epics.ts
import { isOfType } from 'typesafe-actions';
import { ADD } from './constants';
const addTodoToast: Epic<RootAction, RootState, Services> = (action$, store, { toastService }) =>
action$.pipe(
filter(isTypeOf(ADD)),
tap(action => { // here action type is narrowed to: { type: "todos/ADD"; payload: Todo; }
...
PRO-TIP: Both helper above prove usefull in all conditional statements
import { isActionOf, isOfType } from 'typesafe-actions';
if (isActionOf(actions.add, action)) {
// here action is narrowed to: { type: "todos/ADD"; payload: Todo; }
}
// or with type constants
if (isOfType(types.ADD, action)) {
// here action is narrowed to: { type: "todos/ADD"; payload: Todo; }
}
PS: If you're wondering what the
Services
type is in the epics signature and how to declare it in your application to easily inject statically typed API clients to your epics also ensuring for easy mocking while testing resulting in clean architecture, please create an issue for it and perhaps I'll find some time in the future to write an article about it.
- Installation
- API Docs
- utility-types
ActionType
(RootAction type-helper)StateType
(RootState type-helper)
- action-creators
- action-helpers
- utility-types
- Migration Guide
- Compare to others
// NPM
npm install typesafe-actions
// YARN
yarn add typesafe-actions
powerful type helper that will infer union type from "action-creator map" object or "module import"
NB: This helper works similar to ReturnType
but instead of function type paramerer it will accept "typeof action-creators" (it can be "import *" from module or "action-creators map")
import { ActionType } from 'typesafe-actions';
import * as todos from './actions';
export type TodosAction = ActionType<typeof todos>;
const actions = {
action1: createAction('action1'),
nested: {
action2: createAction('action2'),
moreNested: {
action3: createAction('action3'),
}
}
};
export type RootAction = ActionType<typeof actions>;
// RootAction: { type: 'action1' } | { type: 'action2' } | { type: 'action3' }
powerful type helper that will infer state object type from "reducer function" or "nested/combined reducers"
NB: This helper works similar to ReturnType
but instead of function type paramerer it will accept "typeof reducer" or "nested/combined reducers map" (result of combineReducers
)
Redux Combatibility: working with redux@4+ types
import { combineReducers } from 'redux';
import { StateType } from 'typesafe-actions';
//
const todosReducer = (state: Todo[] = [], action: TodosAction) => {
switch (action.type) {
case getType(todos.add):
return [...state, action.payload];
...
export type TodosState = StateType<typeof todosReducer>;
const rootReducer = combineReducers({
router: routerReducer,
counters: countersReducer,
});
export type RootState = StateType<typeof rootReducer>;
simple action factory function, to create typed action
function action(type: T, payload?: P, meta?: M): { type: T, payload?: P, meta?: M }
Examples:
// type with payload
const createUser = (id: number, name: string) =>
action('CREATE_USER', { id, name });
// { type: 'CREATE_USER'; payload: { id: number; name: string }; }
// type with meta
const getUsers = (meta: string) =>
action('GET_USERS', undefined, meta);
// { type: 'GET_USERS'; meta: string; }
create the action-creator of a typesafe compatible action
// type only
function createAction(type: T): () => { type: T };
// createAction('INCREMENT');
// type with payload
function createAction(type: T, executor): (...args) => { type: T, payload: P };
const executor = (resolve) => (...args) => resolve(payload: P)
// createAction('ADD', resolve => {
// return (amount: number) => resolve(amount);
// });
// type with meta
function createAction(type: T, executor): (...args) => { type: T, meta: M };
const executor = (resolve) => (...args) => resolve(payload: undefined, meta: M)
// createAction('ADD', resolve => {
// return (meta: string) => resolve(undefined, meta);
// });
// type with payload and meta
function createAction(type: T, executor): (...args) => { type: T, payload: P, meta: M };
const executor = (resolve) => (...args) => resolve(payload: P, meta: M)
// createAction('GET_TODO', resolve => {
// return (id: string, meta: string) => resolve(id, meta);
// });
Examples:
import { createAction } from 'typesafe-actions';
// type only
const increment = createAction('INCREMENT');
expect(increment())
.toEqual({ type: 'INCREMENT' });
// type with payload
const add = createAction('ADD', resolve => {
return (amount: number) => resolve(amount);
});
expect(add(10))
.toEqual({ type: 'ADD', payload: 10 });
// type with meta
const getTodos = createAction('GET_TODOS', resolve => {
return (meta: string) => resolve(undefined, meta);
});
expect(getTodos('some_meta'))
.toEqual({ type: 'GET_TODOS', meta: 'some_meta' });
// type with payload and meta
const getTodo = createAction('GET_TODO', resolve => {
return (id: string, meta: string) => resolve(id, meta);
});
expect(getTodo('some_id', 'some_meta'))
.toEqual({ type: 'GET_TODO', payload: 'some_id', meta: 'some_meta' });
simple creator compatible with "Flux Standard Action" to reduce boilerplate and enforce convention
function createStandardAction(type: T): <P, M>() => (payload: P, meta: M) => { type: T, payload: P, meta: M };
function createStandardAction(type: T): { map: (payload: P, meta: M): { ...anything } => (...args) => { type: T, ...anything } };
Examples:
import { createAction } from 'typesafe-actions';
// type only
const increment = createStandardAction('INCREMENT')<void>();
expect(increment()).toEqual({ type: 'INCREMENT' });
// type with payload
const add = createStandardAction('ADD')<number>();
expect(add(10)).toEqual({ type: 'ADD', payload: 10 });
// type with meta
const getData = createStandardAction('GET_DATA')<void, string>();
expect(getData(undefined, 'meta')).toEqual({ type: 'GET_DATA', meta: 'meta' });
// type with payload and meta
const notify = createStandardAction('NOTIFY').map(
({ username, message }}: Notification) => ({
payload: `${username}: ${message || ''}`,
meta: { username, message },
})
);
expect(notify({ username: 'Piotr', message: 'Hello!' })).toEqual({
type: 'NOTIFY',
payload: 'Piotr: Hello!',
meta: { username: 'Piotr', message: 'Hello!' },
});
create a composite action-creator containing three action handlers for async flow (e.g. network request - request/success/failure)
function createAsyncAction(requestType: T1, successType: T2, failureType: T3): <P1, P2, P3>() => {
request: AC<T1, P1>,
success: AC<T2, P2>,
failure: AC<T3, P3>,
};
Examples:
import { createAsyncAction } from 'typesafe-actions';
const fetchUsers = createAsyncAction(
'FETCH_USERS_REQUEST',
'FETCH_USERS_SUCCESS',
'FETCH_USERS_FAILURE'
)<void, User[], Error>();
const requestResult = fetchUsers.request();
expect(requestResult).toEqual({
type: 'FETCH_USERS_REQUEST',
});
const successResult = fetchUsers.success([{ firstName: 'Piotr', lastName: 'Witek' }]);
expect(successResult).toEqual({
type: 'FETCH_USERS_SUCCESS',
payload: [{ firstName: 'Piotr', lastName: 'Witek' }],
});
const failureResult = fetchUsers.failure(Error('Failure reason'));
expect(failureResult).toEqual({
type: 'FETCH_USERS_FAILURE',
payload: Error('Failure reason'),
});
get the "type" property of a given action-creator
contains properly narrowed literal type
function getType(actionCreator: (...args) => T): T
Examples:
const increment = createAction('INCREMENT');
const type: 'INCREMENT' = getType(increment);
expect(type).toBe('INCREMENT');
// in reducer
switch (action.type) {
case getType(increment):
return state + 1;
default:
return state;
}
(curried assert function) check if action is an instance of given action-creator(s) it will narrow actions union to a specific action
// can be used as a binary function
isActionOf(actionCreator: ActionCreator<T>, action: any): action is T
isActionOf([actionCreator]: Array<ActionCreator<T>>, action: any): action is T
// or curried function
isActionOf(actionCreator: ActionCreator<T>): (action: any) => action is T
isActionOf([actionCreator]: Array<ActionCreator<T>>): (action: any) => action is T
Examples:
import { addTodo } from './todos-actions';
const addTodoToast: Epic<RootAction, RootState> =
(action$, store) => action$
.filter(isActionOf(addTodo))
.concatMap((action) => { // action is asserted as: { type: "ADD_TODO"; payload: string; }
const toast = `Added new todo: ${action.payload}`;
// epics with multiple actions
import { addTodo, toggleTodo } from './todos-actions';
const logTodoAction: Epic<RootAction, RootState> =
(action$, store) => action$
.filter(isActionOf([addTodo, toggleTodo]))
.concatMap((action) => { // action is asserted as: { type: "ADD_TODO"; payload: string; } | { type: "TOGGLE_TODO"; payload: string; }
const log = `Dispatched action: ${action.type}`;
// conditionals where you need a type guard
import { addTodo } from './actions';
if(isActionOf(addTodo, action)) {
operationThatNeedsPayload(action.payload) // action is asserted as: { type: "ADD_TODO"; payload: string; }
}
(curried assert function) check if action type is equal given type-constant it will narrow actions union to a specific action
// can be used as a binary function
isOfType(type: T, action: any): action is Action<T>
// or curried function
isOfType(type: T): (action: any) => action is T
Examples:
import { ADD } from './todos-types';
const addTodoToast: Epic<RootAction, RootState, Services> =
(action$, store, { toastService }) => action$
.filter(isOfType(ADD))
.do((action) => {
// action is narrowed as: { type: "todos/ADD"; payload: Todo; }
toastService.success(`Added new todo: ${action.payload}`);
})
.ignoreElements();
// conditionals where you need a type guard
import { ADD } from './todos-types';
if(isOfType(ADD, action)) {
return functionThatAcceptsTodo(action.payload) // action: { type: "todos/ADD"; payload: Todo; }
}
NOTE:
[email protected]
are best used with[email protected]
which contains$call
utility that was removed inv2.x.x
// target action creator
getTodo('some_id', 'some_meta'); // { type: 'GET_TODO', payload: 'some_id', meta: 'some_meta' }
// deprecated API
const getTodo = createActionDeprecated('GET_TODO',
(id: string, meta: string) => ({
type: 'GET_TODO',
payload: id,
meta: meta,
})
);
// new API equivalent (we offer 4 different styles - choose your preference)
const getTodoSimple = (id: string, meta: string) => action('GET_TODO', id, meta);
const getTodoVariadic = createAction('GET_TODO', resolve => {
return (id: string, meta: string) => resolve(id, meta);
});
const getTodoStandard = createStandardAction('GET_TODO')<string, string>();
const getTodoStandardWithMap = createStandardAction('GET_TODO').map(
({ id, meta }: { id: string; meta: string; }) => ({
payload: id,
meta,
})
);
Here you can find out a detailed comparison of typesafe-actions
to other solutions.
Lets compare the 3 most common action-creator variants (type only, with payload, with payload and meta)
tested with "@types/redux-actions": "2.2.3"
/**
* redux-actions
*/
const notify1 = createAction('NOTIFY');
// resulting type:
// () => {
// type: string;
// payload: void | undefined;
// error: boolean | undefined;
// }
with
redux-actions
you can notice the redundant nullablepayload
property and literal type oftype
property is lost (discrimination of union type would not be possible) (๐ผ is really sad!)
/**
* typesafe-actions
*/
const notify1 = () => action('NOTIFY');
// resulting type:
// () => {
// type: "NOTIFY";
// }
with
typesafe-actions
there is no excess nullable types, only the data that is really there, also the action "type" property is containing precise literal type
/**
* redux-actions
*/
const notify2 = createAction('NOTIFY',
(username: string, message?: string) => ({
message: `${username}: ${message || 'Empty!'}`,
})
);
// resulting type:
// (t1: string) => {
// type: string;
// payload: { message: string; } | undefined;
// error: boolean | undefined;
// }
first the optional
message
parameter is lost,username
param name is changed to some generict1
, literal type oftype
property is lost again andpayload
is nullable because of broken inference
/**
* typesafe-actions
*/
const notify2 = (username: string, message?: string) => action(
'NOTIFY',
{ message: `${username}: ${message || 'Empty!'}` },
);
// resulting type:
// (username: string, message?: string | undefined) => {
// type: "NOTIFY";
// payload: { message: string; };
// }
typesafe-actions
still retain very precise resulting type
/**
* redux-actions
*/
const notify3 = createAction('NOTIFY',
(username: string, message?: string) => (
{ message: `${username}: ${message || 'Empty!'}` }
),
(username: string, message?: string) => (
{ username, message }
)
);
// resulting type:
// (...args: any[]) => {
// type: string;
// payload: { message: string; } | undefined;
// meta: { username: string; message: string | undefined; };
// error: boolean | undefined;
// }
this time we got a complete loss of arguments arity with falling back to
any
type with all the remaining issues as before
/**
* typesafe-actions
*/
const notify3 = (username: string, message?: string) => action(
'NOTIFY',
{ message: `${username}: ${message || 'Empty!'}` },
{ username, message },
);
// resulting type:
// (username: string, message?: string | undefined) => {
// type: "NOTIFY";
// payload: { message: string; };
// meta: { username: string; message: string | undefined; };
// }
typesafe-actions
never fail toany
type (๐ผ is impressed by completely type-safe results)
MIT License
Copyright (c) 2017 Piotr Witek [email protected] (http://piotrwitek.github.io)