Skip to content

Instantly share code, notes, and snippets.

@TimboKZ
Last active October 16, 2019 11:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save TimboKZ/c3454514a3d40e9795b6b6fe0ea15a3f to your computer and use it in GitHub Desktop.
Save TimboKZ/c3454514a3d40e9795b6b6fe0ea15a3f to your computer and use it in GitHub Desktop.
Ultimate type-safe Redux abstraction
// Library code
type ReduxAction<Type extends string = string> = { type: Type; payload?: any };
type ActionTemplate<P> = (payload: P) => ReduxAction;
type ActionTemplateMap = { [action: string]: ActionTemplate<any> };
type ReduxHandler<State, Payload> = undefined extends Payload
? (state: Readonly<State>) => State
: (state: Readonly<State>, payload: Payload) => State;
type ExtractPayloadMap<T extends ActionTemplateMap> = {
[K in keyof T]: T[K] extends ActionTemplate<infer P> ? P : never;
};
type ReduxHandlerMap<S, ATMap extends ActionTemplateMap> = {
[handlerName in keyof ExtractPayloadMap<ATMap>]: ReduxHandler<S, ExtractPayloadMap<ATMap>[handlerName]>;
};
const action = <T extends string, P>(type: T, payload?: P) => ({ type, payload });
const createSafeReducer = <S extends any, ATMap extends ActionTemplateMap>(
actionTemplates: ATMap,
initialState: S,
handlerMap: ReduxHandlerMap<S, ATMap>,
defaultHandler?: (state: Readonly<S>, action: ReduxAction) => S
) => {
const actionTypeToHandlerMap: { [type: string]: string } = {};
for (const handlerName in actionTemplates) {
if (!actionTemplates.hasOwnProperty(handlerName)) continue;
const actionType = actionTemplates[handlerName](undefined as any).type;
if (actionTypeToHandlerMap[actionType]) {
throw Error(`[createSafeReducer] Duplicate Redux action type found in action templates: ${actionType}`);
}
actionTypeToHandlerMap[actionType] = handlerName;
}
return (state: S = initialState, action: ReduxAction): S => {
const handlerName = actionTypeToHandlerMap[action.type];
if (handlerName && handlerName in handlerMap) {
const handler = (handlerMap as any)[handlerName];
return handler(state, action.payload);
}
if (defaultHandler) {
return defaultHandler(state, action);
}
return state;
};
};
// User code
interface MyState {
username: string | null;
age: number | null;
}
const AuthAction = {
setCredentials: (credentials: { username: string }) => action('auth/SET_CREDENTIALS', credentials),
setAge: (age: number) => action('auth/SET_AGE', age),
clearUserData: () => action('auth/CLEAR_STATE'),
doNothing: () => action('auth/DO_NOTHING'),
} as const;
// can use `AuthAction` as an action creator:
console.log(AuthAction.setAge(25));
// prints: { type: 'auth/SET_AGE', payload: 25 }
const authReducer = createSafeReducer<MyState, typeof AuthAction>(
AuthAction,
{ username: 'Guest', age: -1 },
{
setAge: (state, age) => {
// type of `age` is well-defined
state.username = 'My username'; // type error: `state` is readonly
return { ...state, age };
},
setCredentials: (state, { username }) => {
// type of `username` is well-defined
return { ...state, username, unrelated: 123 };
},
clearUserData: state => {
// if you try to add `payload` function argument above,
// you'll get a type error since action has no payload
return { ...state, username: null, age: null };
},
// the function below throws a type error: action `myRandomAction` was not defined in `AuthAction`
myRandomAction: state => {
return state;
},
// if we remove `myRandomAction` above, `handlerMap` will throw a
// type error - handler for `doNothing` action is not defined on `handlerMap`
}
);
import { Nullable } from 'tsdef';
import { action, createSafeReducer } from '../reduxUtil';
export interface AuthState {
authRequired: boolean;
authenticated: boolean;
username: Nullable<string>;
authError: Nullable<string>;
}
const getInitialAuthState = (): AuthState => ({
authRequired: false,
authenticated: false,
username: null,
authError: null,
});
export const AuthAction = {
setAuthenticated: (authenticated: boolean) => action('auth/SET_AUTHENTICATED', authenticated),
setUsername: (username: string) => action('auth/SET_USERNAME', username),
setAuthError: (authError: string) => action('auth/SET_USERNAME', authError),
clearAuthError: () => action('auth/CLEAR_AUTH_ERROR'),
signOut: () => action('auth/SIGN_OUT'),
} as const;
export const authReducer = createSafeReducer<AuthState, typeof AuthAction>(AuthAction, getInitialAuthState(), {
setAuthenticated: (state, authenticated) => {
if (state.authenticated === authenticated) return state;
return { ...state, authenticated };
},
setUsername: (state, username) => {
if (state.username === username) return state;
return { ...state, username };
},
setAuthError: (state, authError) => {
return { ...state, authError };
},
clearAuthError: state => {
if (state.authError === null) return state;
return { ...state, authError: null };
},
signOut: state => {
return { ...state, isAuthenticated: false, username: null, authError: null };
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment