Last active
October 16, 2019 11:28
-
-
Save TimboKZ/c3454514a3d40e9795b6b6fe0ea15a3f to your computer and use it in GitHub Desktop.
Ultimate type-safe Redux abstraction
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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` | |
} | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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