Skip to content

Instantly share code, notes, and snippets.

@wonderful-panda
Last active March 18, 2018 19:22
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save wonderful-panda/46c072497f8731a2bde28da40e9ea2d7 to your computer and use it in GitHub Desktop.
Save wonderful-panda/46c072497f8731a2bde28da40e9ea2d7 to your computer and use it in GitHub Desktop.
vuex store builder (TypeScript >= 2.1 is required / namespaced module is not supported)
// MIT License
import * as Vuex from "vuex";
/*
* Infrastructure types
*/
export type KV<K extends string, V> = { [_ in K]: V };
export type Payload<T, V> = { type: T } & V;
/*
* Extends vuex types with additional type parameters.
*/
export interface Dispatch<A> {
<K extends keyof A>(payload: Payload<K, A[K]>, options?: Vuex.DispatchOptions): Promise<any[]>;
<K extends keyof A>(type: K, payload: A[K], options?: Vuex.DispatchOptions): Promise<any[]>;
}
export interface Commit<M> {
<K extends keyof M>(payload: Payload<K, M[K]>, options?: Vuex.CommitOptions): void;
<K extends keyof M>(type: K, payload: M[K], options?: Vuex.CommitOptions): void;
}
export interface ActionContext<S, M, A, G, RS> extends Vuex.ActionContext<S, RS> {
dispatch: Dispatch<A>;
commit: Commit<M>;
getters: G;
}
export type MutationTree<S, NewMutations> = {
[K in keyof NewMutations]: (state: S, payload: NewMutations[K]) => any
} & Vuex.MutationTree<S>;
export type ActionTree<S, M, A, G, NewActions, RS> = {
[K in keyof NewActions]: (injectee: ActionContext<S, M, A & NewActions, G, RS>, payload: NewActions[K]) => any
} & Vuex.ActionTree<S, RS>;
export type GetterTree<S, G, NewGetters, RS, RG> = {
[K in keyof NewGetters]: (state: S, getters: G & NewGetters, rootState: RS, rootGetters: RG) => NewGetters[K];
} & Vuex.GetterTree<S, RS>;
/**
* Vuex module with type information
*/
export interface CombinedModule<S, M, A, G, RS, RG> extends Vuex.Module<S, RS> {
// dummy member let this interface behave as phantom type
__types?: {
s: S, m: M, a: A, g: G, rs: RS, rg: RG
};
}
export interface Module<S, M, A, G, RS, RG, CS extends S, CM extends M, CA extends A, CG extends G> extends Vuex.Module<S, RS> {
mutations?: MutationTree<CS, M>;
actions?: ActionTree<CS, CM, CA, CG, A, RS>;
getters?: GetterTree<CS, CG, G, RS, RG>;
}
export interface StoreOptions<S, M, A, G,
CombinedState extends S,
CombinedMutations extends M,
CombinedActions extends A,
CombinedGetters extends G> extends Vuex.StoreOptions<S> {
mutations?: MutationTree<CombinedState, M>;
actions?: ActionTree<CombinedState, CombinedMutations, CombinedActions, CombinedGetters, A, CombinedState>;
getters?: GetterTree<CombinedState, CombinedGetters, G, CombinedState, CombinedGetters>;
}
export interface Store<S, M, A, G> extends Vuex.Store<S> {
dispatch: Dispatch<A>;
commit: Commit<M>;
readonly getters: G;
}
/**
* Vuex store / module builder
*
* @param <CS> combined state (intersection of sub modules' state)
* @param <CM> combined mutations (intersection of sub modules' mutations)
* @param <CA> combined actions (intersection of sub modules' actions)
* @param <CG> combined getters (intersection of sub modules' getters)
* @param <CRS> combined root state (intersection of sub modules' rootstate)
* @param <CRG> combined root getters (intersection of sub modules' rootgetters)
*/
export class Builder<CS, CM, CA, CG, CRS, CRG> {
constructor(private readonly modules: { [name: string]: CombinedModule<any, any, any, any, any, any> }) {
}
/**
* Add module to the builder.
* Added modules will be involved to store as submodules.
*/
addModule<S, M, A, G, RS, RG, N extends string>(name: N, m: CombinedModule<S, M, A, G, RS, RG>):
Builder<CS & KV<N, S>, CM & M, CA & A, CG & G, CRS & RS, CRG & RG> {
const newModules = Object.assign({}, this.modules, { [<string>name]: m });
return new Builder<CS & KV<N, S>, CM & M, CA & A, CG & G, CRS & RS, CRG & RG>(newModules);
}
/**
* Specify types of root state and root getters
* new types must be extended from current types
*/
rootTypes<RootState extends CRS, RootGetters extends CRG>(): Builder<CS, CM, CA, CG, CRS, CRG> {
return this;
}
/**
* create store instance from specified store options and previously added submodules.
*
* @param <S> type of state
* @param <M> type of mutations (optional)
* @param <A> type of actions (optional)
* @param <G> type of getters (optional)
* @params options store options
*/
createStore<S extends CRS>(options: StoreOptions<S, {}, {}, {}, CS & S, CM, CA, CG>): Store<CS & S, CM, CA, CG>;
createStore<S extends CRS, M>(options: StoreOptions<S, M, {}, {}, CS & S, CM & M, CA, CG>): Store<CS & S, CM & M, CA, CG>;
createStore<S extends CRS, M, A>(options: StoreOptions<S, M, A, {}, CS & S, CM & M, CA & A, CG>): Store<CS & S, CM & M, CA & A, CG>;
createStore<S extends CRS, M, A, G>(options: StoreOptions<S, M, A, G, CS & S, CM & M, CA & A, CG & G>): Store<CS & S, CM & M, CA & A, CG & G>;
createStore(options: Vuex.StoreOptions<any>) {
let { modules, ...rest } = options;
modules = Object.assign(Object.create(null), modules, this.modules);
return new Vuex.Store(Object.assign(rest, { modules }));
}
/**
* create combined module from specified modules and previously added submodules.
*
* @param <S> type of state
* @param <M> type of mutations (optional)
* @param <A> type of actions (optional)
* @param <G> type of getters (optional)
* @params options store options
*/
createModule<S>(m: Module<S, {}, {}, {}, CRS, CRG, CS & S, CM, CA, CG>): CombinedModule<CS & S, CM, CA, CG, CRS, CRG>;
createModule<S, M>(m: Module<S, M, {}, {}, CRS, CRG, CS & S, CM & M, CA, CG>): CombinedModule<CS & S, CM & M, CA, CG, CRS, CRG>;
createModule<S, M, A>(m: Module<S, M, A, {}, CRS, CRG, CS & S, CM & M, CA & A, CG>): CombinedModule<CS & S, CM & M, CA & A, CG, CRS, CRG>;
createModule<S, M, A, G>(m: Module<S, M, A, G, CRS, CRG, CS & S, CM & M, CA & A, CG & G>): CombinedModule<CS & S, CM & M, CA & A, CG & G, CRS, CRG>;
createModule(m: Vuex.Module<any, any>) {
let { modules, ...rest } = m;
modules = Object.assign(Object.create(null), modules, this.modules);
return Object.assign({ modules }, rest);
}
}
/**
* Default builder instance
*/
export const builder = new Builder(Object.create(null));
import { builder } from "./builder";
interface ModuleState {
value: number;
}
interface ModuleMutations {
"mod1/setValue": number;
}
interface ModuleActions {
"mod1/increment": undefined;
}
export default builder.createModule<ModuleState, ModuleMutations, ModuleActions>({
state: { value: 1 },
mutations: {
// Check method names and infer payload types
["mod1/setValue"](state, value) { // state is inferred as ModuleState, value is inferred as number.
state.value = value;
}
},
actions: {
// Check method names and infer payload types
["mod1/increment"](context, _) {
// First parameter must be "mod1/setValue". Second must be number. (Compiler checks them)
context.commit("mod1/setValue", context.state.value + 1);
}
}
});
import { builder } from "./builder";
import module1 from "./module1";
interface State {
root: number;
}
interface Mutations {
"setRoot": number;
}
interface Actions {
"increment": undefined;
}
const store = builder.addModule(
// corresponding to { modules: { child: module1 } }
// this will intersect types from module1, and make enable to call mutations/actions of module1
"child", module1
).createStore<State, Mutations, Actions>({
state: { root: 1 },
mutations: {
setRoot(state, value) { // type of state will be { root: number, child: { value: number } } (State & { child: ModuleState })
state.root = value;
}
},
actions: {
increment(context, _) {
context.commit("setRoot", context.state.root + 1);
context.dispatch("mod1/increment", undefined); // module1 actions can be dispatched here.
}
}
});
store.commit("setRoot", 1); // OK
store.commit("mod1/setValue", 1); // OK
store.commit("setRoot", "foo"); // Compile error
store.commit("mod1/setValue", "foo"); // Compile error
store.commit("setRot", 1); // Compile error
store.dispatch("increment", undefined); // OK
store.dispatch("mod1/increment", undefined); // OK
store.dispatch("increment", 1); // Compile error
store.dispatch("incrment", undefined); // Compile error
@Glidias
Copy link

Glidias commented Jan 26, 2017

How would a module1 action be able to respond to "increment" as well from root store? Do I manuallyinclude it in with & operator?

import * as ActionsRoot from "./actions_root";

builder.createModule<ModuleState, ModuleMutations, ModuleActions&ActionsRoot.Actions>({
    actions: {

        ["mod1/increment"](context, val) {   
        },
      incrementBy(context, val) {

        },

        shouldReportErrorUndefinedProperty() {  // <- minor caveat, seems to allow adding additional properties

        }
    }
});

Take note of the minor caveat above. This is caused due to you relying on & operator for , which if commented away, will ensure strict typings only to given keyofs, and will not allow extra properties. You might need to re-factor it though to get it to compile again.

export type MutationTree<S, NewMutations>  = {
    [K in keyof NewMutations]: (state: S, payload: NewMutations[K]) => any;
} /*& Vuex.MutationTree<S>*/

Here's what i attempted with standalone MutationTree without applying & Vuex.MutationTree<S>.

export type personIdentifier = 'adults' | 'juveniles' | 'children'

// keeping properties optional will allow for better modular implementations

export type  MutationTypes =  {
    INC?:personIdentifier;
    DEC?:personIdentifier;
    SWAP?:undefined;
}

export type  MutationTypes2 =  {
    INC2?:personIdentifier;
    DEC2?:personIdentifier;
    SWAP2?:undefined;
}

export type MutationTree2<S, NewMutations>  = {
    [K in keyof NewMutations]: (state: S, payload: NewMutations[K]) => any;
} 

var tree:MutationTree2<State, MutationTypes&MutationTypes2> = {
    INC(state, key) {
        state[key]++
    },
    DEC(state, key) {
        state[key]--
    },
    SWAP(state) {
    const { adults, juveniles } = state
    state.juveniles = adults 
    state.adults = juveniles
    },
    extra() {  // now reports errors

    }
}

@Glidias
Copy link

Glidias commented Jan 27, 2017

How to strictly type context.rootState and context.rootGetters in actions for createModule<S,M,A,G> without having to write a bunch of repeated type parameters in the handler functions to impose an assumed type???

actions: {
    // Check method names and infer payload types
    ["mod1/increment"](context:ActionContext<ModuleState,ModuleMutations,ModuleActions,ModuleGetters,State,StateGetters>, _) {
        
       // context.getters.value_mod1;
        // First parameter must be "mod1/setValue". Second must be number. (Compiler checks them)
        context.commit("mod1/setValue", context.state.value + 1);
        
    },

Also, when writing module.ts., it appears impossible for rootState within a Module to be able to determine (in a strictly typed manner), any other registered module states under that given assumed rootState type. Normally, rootState should also contain the combined state of all other modules that may be added in the future. However, since a module is created first before the store with only 4 parameters of SMAG, there's no way a module can effectively predict what other modules under the given root state type it needs to interact with.

Also, shouldn't createModule also include RS param after SMAG?, so that the final createStore may validate against any RS ) that was set on builder and ensure S matches the RS found on previously added modules?. Not sure if it's possible to do in Typescript.

The same issue occurs likewise for rootGetters, since there might be other modules' getters registered in the future to global root namespace as well which aren't accounted for within a module. Also, since a non-namespaced module's getters parameter in Getter or Action context handlers always refer to root namespace anyway, any manually specified "getters" types (eg. the G in SMAG or the RG), won't be "truly accruate" as well, since it refers to global namespace as well (ie. it won't reflect the "full actual state" of fields found on global namespace) Thus, it would seem as far as root getters are concerned, dangerous assumptions over what root getter fields are dynamically available or not may end up being manually imposed, which isn't good.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment