diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index ce35718c0..341645938 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -18,4 +18,5 @@ - [Form Handling](forms.md) - [Testing](testing.md) - [Hot Reloading](hot-reload.md) +- [TypeScript Support](typescript.md) - [API Reference](api.md) diff --git a/docs/en/api.md b/docs/en/api.md index e117cc79f..ef1aa9180 100644 --- a/docs/en/api.md +++ b/docs/en/api.md @@ -212,6 +212,8 @@ const store = new Vuex.Store({ ...options }) The first argument can optionally be a namespace string. [Details](modules.md#binding-helpers-with-namespace) -- **`createNamespacedHelpers(namespace: string): Object`** +- **`createNamespacedHelpers(namespace?: string): Object`** Create namespaced component binding helpers. The returned object contains `mapState`, `mapGetters`, `mapActions` and `mapMutations` that are bound with the given namespace. [Details](modules.md#binding-helpers-with-namespace) + + If the namespace is not specified, it returns the root mapXXX helpers. This is mainly for TypeScript users to annotate root helper's type. diff --git a/docs/en/typescript.md b/docs/en/typescript.md new file mode 100644 index 000000000..42917b481 --- /dev/null +++ b/docs/en/typescript.md @@ -0,0 +1,230 @@ +# TypeScript Support + +## Utility Types for Modules + +Vuex provides some utility types to help you to declare modules in TypeScript. They avoid runtime errors when using state, getters, mutations and actions in a module thanks to type checking. + +To use the utility types, you should declare module assets types at first. Following is a simple example of counter module types: + +```ts +// State type +export interface CounterState { + count: number +} + +// Getters type +// key: getter name +// value: return type of getter +export interface CounterGetters { + power: number +} + +// Mutations type +// key: mutation name +// value: payload type of mutation +export interface CounterMutations { + increment: { amount: number } +} + +// Actions type +// key: action name +// value: payload type of action +export interface CounterActions { + incrementAsync: { amount: number, delay: number } +} +``` + +The state type must describe an actual state shape. The `CounterState` in the example indicates that the module's state has `count` property which must fulfill `number` type. + +The getters type describes what getter names exist in the module according to keys. The corresponding value type shows what type the getter returns. The `CounterGetters` in the example indicates that the module has a getter named `power` and it returns a value of type `number`. + +Both the actions and mutations type describe what thier names exist in the module as same as getters type. The value type of them indicates the payload type. The `CounterMutations` illustrates that the module has `increment` mutation and its payload is an object having `amount` property of type `number`, while the `CounterActions` shows there is `incrementAsync` action with an object payload having `amount` and `delay` property of type `number` in the module. + +After declaring the module assets types, you import `DefineModule` utility type and annotate the module with it: + +```ts +import { DefineModule } from 'vuex' + +// Implementation of counter module +export const counter: DefineModule = { + namespaced: true, + + // Follow CounterState + state: { + count: 0 + }, + + // Follow CounterGetters + getters: { + power: state => state.count * state.count + }, + + // Follow CounterMutations + mutations: { + increment (state, payload) { + state.count += payload.amount + } + }, + + // Follow CounterActions + actions: { + incrementAsync ({ commit }, payload) { + setTimeout(() => { + commit('increment', { amount: payload.amount }) + }, payload.delay) + } + } +} +``` + +Note that all function arguments types are inferred without manually annotating them including `dispatch` and `commit` in the action context. If you try to dispach an action (commit a mutation) that does not exist or the payload type is not valid on the declared types, it throws a compilation error. + +### Using external modules in the same namespace + +Sometimes you may want to use external modules' getters, actions and mutations in the same namespace. In that case, you can pass the external module assets types to `DefineModule` generic parameters to extend the module type: + +```ts +// External module assets types +// You may import them from another file on a practical code +interface ExternalGetters { + extraValue: number +} + +interface ExternalMutations { + loading: boolean +} + +interface ExternalActions { + sendTrackingData: { name: string, value: string } +} + +export const counter: DefineModule< + // The first 4 type parameters are for module assets + CounterState, + CounterGetters, + CounterMutations, + CounterActions, + + // 3 type parameters that follows the module assets types are external module assets types + ExternalGetters, + ExternalMutations, + ExternalActions +> = { + namespaced: true, + + state: { /* ... */ }, + mutations: { /* ... */ }, + + getters: { + power (state, getters) { + // You can use a getter from the external module + console.log(getters.extraValue) + return state.count * state.count + } + }, + + actions: { + incrementAsync ({ commit, dispatch }, payload) { + // Using the external action + dispatch('sendTrackingData', { + name: 'increment', + value: payload.amount + }) + + // Using the external mutation + commit('loading', true) + setTimeout(() => { + commit('increment', { amount: payload.amount }) + commit('loading', false) + }, payload.delay) + } + } +} +``` + +### Using the root state, getters, actions and mutations + +If you want to use root state, getters, actions and mutations, you can pass root assets types following external assets types on `DefineModule`: + +```ts +export const counter: DefineModule< + CounterState, + CounterGetters, + CounterMutations, + CounterActions, + + // You can use `{}` type if you will not use them + {}, // External getters + {}, // External mutations + {}, // External actions + + // Root types can be specified after external assets types + RootState, + RootGetters, + RootMutations, + RootActions +> = { + /* ... module implementation ... */ +} +``` + +## Typed Component Binding Helpers + +You probably want to use fully typed `state`, `getters`, `dispatch` and `commit` not only in modules but also from components. You can use `createNamespacedHelpers` to use typed module assets on components. The `createNamespacedHelpers` accepts 4 generic parameters to annotate returned `mapState`, `mapGetters`, `mapMutations` and `mapActions` by using module assets types: + +```ts +export const counterHelpers = createNamespacedHelpers('counter') +``` + +All the returned helpers and mapped computed properties and methods will be type checked. You can use them without concerning typos and invalid payload by yourself: + +```ts +export default Vue.extend({ + computed: counterHelpers.mapState({ + value: 'count' + }), + + methods: counterHelpers.mapMutations({ + inc: 'increment' + }), + + created () { + // These are correctly typed! + this.inc({ amount: 1 }) + console.log(this.value) + } +}) +``` + +### Annotating Root Binding Helpers + +`createNamespacedHelpers` is made for generating new component binding helpers focusing a namespaced module. The API however is useful to create typed root binding helpers. So if you need them, you call `createNamespacedHelpers` without passing namespace: + +```ts +const rootHelpers = createNamespacedHelpers() +``` + +## Explicit Payload + +While regular (not strictly typed) `dispatch` and `commit` can omit a payload, typed ones does not allow to omit it. This is because to ensure type safety of a payload. If you want to declare actions / mutations that do not have a payload you should explicitly pass `undefined` value. + +```ts +export interface CounterMutation { + // This indicates the `increment` action does not have a payload + increment: undefined +} + +// ... +export const counter: DefineModule = { + // ... + + actions: { + someAction ({ commit }) { + // Passing `undefined` value explicitly + commit('increment', undefined) + } + } +} +``` + +This restriction might be changed after TypeScript implements [the conditional types](https://github.com/Microsoft/TypeScript/pull/21316) in the future. diff --git a/package-lock.json b/package-lock.json index 36b8e76dd..02e6bfa5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7776,9 +7776,9 @@ "dev": true }, "typescript": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.5.3.tgz", - "integrity": "sha512-ptLSQs2S4QuS6/OD1eAKG+S5G8QQtrU5RT32JULdZQtM1L3WTi34Wsu48Yndzi8xsObRAB9RPt/KhA9wlpEF6w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.6.1.tgz", + "integrity": "sha1-7znN6ierrAtQAkLWcmq5DgyEZjE=", "dev": true }, "uglify-js": { @@ -7988,9 +7988,9 @@ } }, "vue": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.5.0.tgz", - "integrity": "sha512-KngZQLLe/N2Bvl3qu0xgqQHemm9MNz9y73D7yJ5tVavOKyhSgCLARYzrXJzYtoeadUSrItzV36VrHywLGVUx7w==", + "version": "2.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.5.13.tgz", + "integrity": "sha512-3D+lY7HTkKbtswDM4BBHgqyq+qo8IAEE8lz8va1dz3LLmttjgo0FxairO4r1iN2OBqk8o1FyL4hvzzTFEdQSEw==", "dev": true }, "vue-hot-reload-api": { @@ -8085,9 +8085,9 @@ } }, "vue-template-compiler": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.5.0.tgz", - "integrity": "sha512-W4hDoXXpCwfilO1MRTDM4EHm1DC1mU1wS8WyvEo119cUtxdaPuq/dD0OJbSEIkeW8fdT07qGCSnLOfPlmrKRqw==", + "version": "2.5.13", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.5.13.tgz", + "integrity": "sha512-15HWSgIxrGUcV0v7QRen2Y3fQsbgxXwMvjT/5XKMO0ANmaCcNh7y2OeIDTAuSGeosjb9+E1Pn2PHZ61VQWEgBQ==", "dev": true, "requires": { "de-indent": "1.0.2", diff --git a/package.json b/package.json index 700927af3..26dad3826 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dist", "types/index.d.ts", "types/helpers.d.ts", - "types/vue.d.ts" + "types/vue.d.ts", + "types/utils.d.ts" ], "scripts": { "dev": "node examples/server.js", @@ -65,11 +66,11 @@ "rollup-watch": "^4.3.1", "selenium-server": "^2.53.1", "todomvc-app-css": "^2.1.0", - "typescript": "^2.5.3", + "typescript": "^2.6.1", "uglify-js": "^3.1.2", - "vue": "^2.5.0", + "vue": "^2.5.13", "vue-loader": "^13.3.0", - "vue-template-compiler": "^2.5.0", + "vue-template-compiler": "^2.5.13", "webpack": "^3.7.1", "webpack-dev-middleware": "^1.10.0", "webpack-hot-middleware": "^2.19.1" diff --git a/src/helpers.js b/src/helpers.js index c2f319afe..e3cc43b5d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -111,14 +111,16 @@ export const mapActions = normalizeNamespace((namespace, actions) => { /** * Rebinding namespace param for mapXXX function in special scoped, and return them by simple object - * @param {String} namespace + * If the namespace is not specified, it returns the root mapXXX helpers. + * This is mainly for TypeScript users to annotate root helper's type. + * @param {String} [namespace] * @return {Object} */ export const createNamespacedHelpers = (namespace) => ({ - mapState: mapState.bind(null, namespace), - mapGetters: mapGetters.bind(null, namespace), - mapMutations: mapMutations.bind(null, namespace), - mapActions: mapActions.bind(null, namespace) + mapState: namespace ? mapState.bind(null, namespace) : mapState, + mapGetters: namespace ? mapGetters.bind(null, namespace) : mapGetters, + mapMutations: namespace ? mapMutations.bind(null, namespace) : mapMutations, + mapActions: namespace ? mapActions.bind(null, namespace) : mapActions }) /** diff --git a/test/unit/helpers.spec.js b/test/unit/helpers.spec.js index 1bc0fc409..118beb9cc 100644 --- a/test/unit/helpers.spec.js +++ b/test/unit/helpers.spec.js @@ -517,4 +517,56 @@ describe('Helpers', () => { vm.actionB() expect(actionB).toHaveBeenCalled() }) + + it('createNamespacedHelpers: generates root helpers', () => { + const actionA = jasmine.createSpy() + const actionB = jasmine.createSpy() + const store = new Vuex.Store({ + state: { count: 0 }, + getters: { + isEven: state => state.count % 2 === 0 + }, + mutations: { + inc: state => state.count++, + dec: state => state.count-- + }, + actions: { + actionA, + actionB + } + }) + const { + mapState, + mapGetters, + mapMutations, + mapActions + } = createNamespacedHelpers() + const vm = new Vue({ + store, + computed: { + ...mapState(['count']), + ...mapGetters(['isEven']) + }, + methods: { + ...mapMutations(['inc', 'dec']), + ...mapActions(['actionA', 'actionB']) + } + }) + expect(vm.count).toBe(0) + expect(vm.isEven).toBe(true) + store.state.count++ + expect(vm.count).toBe(1) + expect(vm.isEven).toBe(false) + vm.inc() + expect(store.state.count).toBe(2) + expect(store.getters.isEven).toBe(true) + vm.dec() + expect(store.state.count).toBe(1) + expect(store.getters.isEven).toBe(false) + vm.actionA() + expect(actionA).toHaveBeenCalled() + expect(actionB).not.toHaveBeenCalled() + vm.actionB() + expect(actionB).toHaveBeenCalled() + }) }) diff --git a/types/helpers.d.ts b/types/helpers.d.ts index 36f1c7418..d0c08911c 100644 --- a/types/helpers.d.ts +++ b/types/helpers.d.ts @@ -1,69 +1,118 @@ import Vue from 'vue'; import { Dispatch, Commit } from './index'; -type Dictionary = { [key: string]: T }; -type Computed = () => any; -type MutationMethod = (...args: any[]) => void; -type ActionMethod = (...args: any[]) => Promise; -type CustomVue = Vue & Dictionary; - -interface Mapper { - (map: string[]): Dictionary; - (map: Dictionary): Dictionary; +/** + * Utility types to declare helper types + */ +type Computed = () => R; +type Method = (...args: any[]) => R; +type CustomVue = Vue & Record; + +interface BaseType { [key: string]: any } + +interface BaseStateMap { + [key: string]: (this: CustomVue, state: State, getters: Getters) => any; +} + +interface BaseMethodMap { + [key: string]: (this: CustomVue, fn: F, ...args: any[]) => any; +} + +type MethodType = 'optional' | 'normal' + +/** + * Return component method type for a mutation. + * You can specify `Type` to choose whether the argument is optional or not. + */ +type MutationMethod = { + optional: (payload?: P) => void; + normal: (payload: P) => void; +}[Type]; + +/** + * Return component method type for an action. + * You can specify `Type` to choose whether the argument is optional or not. + */ +type ActionMethod = { + optional: (payload?: P) => Promise; + normal: (payload: P) => Promise; +}[Type]; + +/** + * mapGetters + */ +interface MapGetters { + (map: Key[]): { [K in Key]: Computed }; + >(map: Map): { [K in keyof Map]: Computed }; +} + +interface RootMapGetters extends MapGetters { + (namespace: string, map: Key[]): { [K in Key]: Computed }; + >(namespace: string, map: Map): { [K in keyof Map]: Computed }; +} + +/** + * mapState + */ +interface MapState { + (map: Key[]): { [K in Key]: Computed }; + >(map: Map): { [K in keyof Map]: Computed }; + >(map: Map): { [K in keyof Map]: Computed }; } -interface MapperWithNamespace { - (namespace: string, map: string[]): Dictionary; - (namespace: string, map: Dictionary): Dictionary; +interface RootMapState extends MapState { + (namespace: string, map: Key[]): { [K in Key]: Computed }; + >(namespace: string, map: Map): { [K in keyof Map]: Computed }; + >(namespace: string, map: Map): { [K in keyof Map]: Computed }; } -interface FunctionMapper { - (map: Dictionary<(this: CustomVue, fn: F, ...args: any[]) => any>): Dictionary; +/** + * mapMutations + */ +interface MapMutations { + (map: Key[]): { [K in Key]: MutationMethod }; + >(map: Map): { [K in keyof Map]: MutationMethod }; + >>(map: Map): { [K in keyof Map]: Method }; } -interface FunctionMapperWithNamespace { - ( - namespace: string, - map: Dictionary<(this: CustomVue, fn: F, ...args: any[]) => any> - ): Dictionary; +interface RootMapMutations extends MapMutations { + (namespace: string, map: Key[]): { [K in Key]: MutationMethod }; + >(namespace: string, map: Map): { [K in keyof Map]: MutationMethod }; + >>(namespace: string, map: Map): { [K in keyof Map]: Method }; } -interface MapperForState { - ( - map: Dictionary<(this: CustomVue, state: S, getters: any) => any> - ): Dictionary; +/** + * mapActions + */ +interface MapActions { + (map: Key[]): { [K in Key]: ActionMethod }; + >(map: Map): { [K in keyof Map]: ActionMethod }; + >>(map: Map): { [K in keyof Map]: Method }; } -interface MapperForStateWithNamespace { - ( - namespace: string, - map: Dictionary<(this: CustomVue, state: S, getters: any) => any> - ): Dictionary; +interface RootMapActions extends MapActions { + (namespace: string, map: Key[]): { [K in Key]: ActionMethod }; + >(namespace: string, map: Map): { [K in keyof Map]: ActionMethod }; + >>(namespace: string, map: Map): { [K in keyof Map]: Method }; } -interface NamespacedMappers { - mapState: Mapper & MapperForState; - mapMutations: Mapper & FunctionMapper; - mapGetters: Mapper; - mapActions: Mapper & FunctionMapper; +/** + * namespaced helpers + */ +interface NamespacedMappers { + mapState: MapState; + mapGetters: MapGetters; + mapMutations: MapMutations; + mapActions: MapActions; } -export declare const mapState: Mapper - & MapperWithNamespace - & MapperForState - & MapperForStateWithNamespace; +export declare const mapState: RootMapState; -export declare const mapMutations: Mapper - & MapperWithNamespace - & FunctionMapper - & FunctionMapperWithNamespace; +export declare const mapMutations: RootMapMutations; -export declare const mapGetters: Mapper - & MapperWithNamespace; +export declare const mapGetters: RootMapGetters; -export declare const mapActions: Mapper - & MapperWithNamespace - & FunctionMapper - & FunctionMapperWithNamespace; +export declare const mapActions: RootMapActions; -export declare function createNamespacedHelpers(namespace: string): NamespacedMappers; +export declare function createNamespacedHelpers(namespace?: string): NamespacedMappers; +export declare function createNamespacedHelpers(namespace?: string): NamespacedMappers; diff --git a/types/index.d.ts b/types/index.d.ts index bc1477cb2..a1229e818 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,7 +3,20 @@ import _Vue, { WatchOptions } from "vue"; // augment typings of Vue.js import "./vue"; -export * from "./helpers"; +export { + mapState, + mapGetters, + mapActions, + mapMutations, + createNamespacedHelpers +} from "./helpers"; + +export { + DefineModule, + DefineGetters, + DefineMutations, + DefineActions +} from './utils' export declare class Store { constructor(options: StoreOptions); @@ -36,29 +49,77 @@ export declare class Store { export declare function install(Vue: typeof _Vue): void; -export interface Dispatch { - (type: string, payload?: any, options?: DispatchOptions): Promise; -

(payloadWithType: P, options?: DispatchOptions): Promise; +/** + * Strict version of dispatch type. It always requires a payload. + */ +interface StrictDispatch { + // Local + (type: K, payload: Actions[K], options?: LocalDispatchOptions): Promise; + (payloadWithType: InputPayload, options?: LocalDispatchOptions): Promise; + + // Root + (type: K, payload: RootActions[K], options: RootDispatchOptions): Promise; + (payloadWithType: InputPayload, options: RootDispatchOptions): Promise; } -export interface Commit { - (type: string, payload?: any, options?: CommitOptions): void; -

(payloadWithType: P, options?: CommitOptions): void; +/** + * Strict version of commit type. It always requires a payload. + */ +interface StrictCommit { + // Local + (type: K, payload: Mutations[K], options?: LocalCommitOptions): void; + (payloadWithType: InputPayload, options?: LocalCommitOptions): void; + + // Root + (type: K, payload: RootMutations[K], options: RootCommitOptions): void; + (payloadWithType: InputPayload, options: RootCommitOptions): void; } -export interface ActionContext { - dispatch: Dispatch; - commit: Commit; +/** + * Loose dispatch type. It can omit a payload and may throw in run time + * since type checker cannot detect whether omitting payload is safe or not. + */ +export interface Dispatch, RootActions = Record> extends StrictDispatch { + (type: K): Promise; +} + +/** + * Loose commit type. It can omit a payload and may throw in run time + * since type checker cannot detect whether omitting payload is safe or not. + */ +export interface Commit, RootMutations = Record> extends StrictCommit { + (type: K): void; +} + +export interface StrictActionContext { + dispatch: StrictDispatch; + commit: StrictCommit; state: S; - getters: any; - rootState: R; - rootGetters: any; + getters: G; + rootState: RS; + rootGetters: RG; +} + +export interface ActionContext< + S, + RS, + G = any, + RG = any, + M = Record, + RM = Record, + A = Record, + RA = Record +> extends StrictActionContext { + dispatch: Dispatch; + commit: Commit; } export interface Payload { type: string; } +type InputPayload = { type: K } & P[K] + export interface MutationPayload extends Payload { payload: any; } @@ -67,6 +128,22 @@ export interface ActionPayload extends Payload { payload: any; } +interface LocalDispatchOptions extends DispatchOptions { + root?: false; +} + +interface RootDispatchOptions extends DispatchOptions { + root: true; +} + +interface LocalCommitOptions extends CommitOptions { + root?: false; +} + +interface RootCommitOptions extends CommitOptions { + root: true; +} + export interface DispatchOptions { root?: boolean; } diff --git a/types/test/helpers.ts b/types/test/helpers.ts index 569a9ecaf..4d83599ac 100644 --- a/types/test/helpers.ts +++ b/types/test/helpers.ts @@ -11,79 +11,109 @@ import { const helpers = createNamespacedHelpers('foo'); new Vue({ - computed: Object.assign({}, - mapState(["a"]), - mapState('foo', ["a"]), - mapState({ - b: "b" - }), - mapState('foo', { - b: "b" - }), - mapState({ - c: (state: any, getters: any) => state.c + getters.c + computed: { + ...mapState(["a"]), + ...mapState('foo', ["b"]), + ...mapState({ + c: "c" + }), + ...mapState('foo', { + d: "d" + }), + ...mapState({ + e: (state: any, getters: any) => { + return state.a + getters.a + } }), - mapState('foo', { - c: (state: any, getters: any) => state.c + getters.c + ...mapState('foo', { + f: (state: any, getters: any) => { + return state.c + getters.c + }, + useThis (state: any, getters: any) { + return state.c + getters.c + this.whatever + } }), - mapGetters(["d"]), - mapGetters('foo', ["d"]), - mapGetters({ - e: "e" - }), - mapGetters('foo', { - e: "e" + ...helpers.mapState(["g"]), + ...helpers.mapState({ + h: "h" }), + ...helpers.mapState({ + i: (state: any, getters: any) => state.k + getters.k + }) + }, + + created () { + this.a + this.b + this.c + this.d + this.e + this.f + this.g + this.h + this.i + } +}) - helpers.mapState(["k"]), - helpers.mapState({ - k: "k" +new Vue({ + computed: { + ...mapGetters(["a"]), + ...mapGetters('foo', ["b"]), + ...mapGetters({ + c: "c" }), - helpers.mapState({ - k: (state: any, getters: any) => state.k + getters.k, - useThis(state: any, getters: any) { - return state.k + getters.k + this.whatever - } + ...mapGetters('foo', { + d: "d" }), - helpers.mapGetters(["l"]), - helpers.mapGetters({ - l: "l" + ...helpers.mapGetters(["e"]), + ...helpers.mapGetters({ + f: "f" }), - { - otherComputed () { - return "f"; - } + otherComputed () { + return "g"; } - ), + }, - methods: Object.assign({}, - mapActions(["g"]), - mapActions({ - h: "h" + created () { + this.a + this.b + this.c + this.d + this.e + this.f + this.otherComputed + } +}) + +new Vue({ + methods: { + ...mapActions(["a"]), + ...mapActions({ + b: "b" }), - mapActions({ - g (dispatch, a: string, b: number, c: boolean): void { - dispatch('g', { a, b, c }) + ...mapActions({ + c (dispatch, a: string, b: number, c: boolean): void { + dispatch('c', { a, b, c }) dispatch({ - type: 'g', + type: 'c', a, b, c }) } }), - mapActions('foo', ["g"]), - mapActions('foo', { - h: "h" + ...mapActions('foo', ["d"]), + ...mapActions('foo', { + e: "e" }), - mapActions('foo', { - g (dispatch, a: string, b: number, c: boolean): void { - dispatch('g', { a, b, c }) + ...mapActions('foo', { + f (dispatch, a: string, b: number, c: boolean): void { + dispatch('f', { a, b, c }) dispatch({ - type: 'g', + type: 'f', a, b, c @@ -91,30 +121,57 @@ new Vue({ } }), - mapMutations(["i"]), - mapMutations({ - j: "j" + ...helpers.mapActions(["g"]), + ...helpers.mapActions({ + h: "h" + }), + ...helpers.mapActions({ + i (dispatch, value: string) { + dispatch('i', value) + } + }) + }, + + created () { + this.a(1) + this.b(2) + this.c('a', 3, true) + this.d(4) + this.e(5) + this.f(6) + this.g(7) + this.h(8) + this.i(9) + this.a() // should allow 0-argument call if untyped + } +}) + +new Vue({ + methods: { + ...mapMutations(["a"]), + ...mapMutations({ + b: "b" }), - mapMutations({ - i (commit, a: string, b: number, c: boolean): void { - commit('i', { a, b, c }) + ...mapMutations({ + c (commit, a: string, b: number, c: boolean): void { + commit('c', { a, b, c }) commit({ - type: 'i', + type: 'c', a, b, c }) } }), - mapMutations('foo', ["i"]), - mapMutations('foo', { - j: "j" + ...mapMutations('foo', ["d"]), + ...mapMutations('foo', { + e: "e" }), - mapMutations('foo', { - i (commit, a: string, b: number, c: boolean): void { - commit('i', { a, b, c }) + ...mapMutations('foo', { + f (commit, a: string, b: number, c: boolean): void { + commit('f', { a, b, c }) commit({ - type: 'i', + type: 'f', a, b, c @@ -122,28 +179,30 @@ new Vue({ } }), - helpers.mapActions(["m"]), - helpers.mapActions({ - m: "m" + ...helpers.mapMutations(["g"]), + ...helpers.mapMutations({ + h: "h" }), - helpers.mapActions({ - m (dispatch, value: string) { - dispatch('m', value) + ...helpers.mapMutations({ + i (commit, value: string) { + commit('i', value) } }), - helpers.mapMutations(["n"]), - helpers.mapMutations({ - n: "n" - }), - helpers.mapMutations({ - n (commit, value: string) { - commit('m', value) - } - }), + otherMethod () {} + }, - { - otherMethod () {} - } - ) + created () { + this.a(1) + this.b(2) + this.c('a', 3, true) + this.d(4) + this.e(5) + this.f(6) + this.g(7) + this.h(8) + this.i(9) + this.otherMethod() + this.a() // should allow 0-argument call if untyped + } }); diff --git a/types/test/index.ts b/types/test/index.ts index 0253e5df8..c19a28277 100644 --- a/types/test/index.ts +++ b/types/test/index.ts @@ -14,12 +14,14 @@ namespace StoreInstance { store.state.value; store.getters.foo; + store.dispatch("foo"); store.dispatch("foo", { amount: 1 }).then(() => {}); store.dispatch({ type: "foo", amount: 1 }).then(() => {}); + store.commit("foo"); store.commit("foo", { amount: 1 }); store.commit({ type: "foo", @@ -61,8 +63,18 @@ namespace RootModule { foo ({ state, getters, dispatch, commit }, payload) { state.value; getters.count; - dispatch("bar", {}); - commit("bar", {}); + dispatch("bar"); + dispatch("bar", { value: 1 }); + dispatch({ + type: "bar", + value: 1 + }); + commit("bar"); + commit("bar", { value: 1 }); + commit({ + type: "bar", + value: 1 + }); } }, mutations: { diff --git a/types/test/shopping-cart/api/shop.ts b/types/test/shopping-cart/api/shop.ts new file mode 100644 index 000000000..41e17da1a --- /dev/null +++ b/types/test/shopping-cart/api/shop.ts @@ -0,0 +1,4 @@ +import { Product } from '../store/modules/products' + +export declare function buyProducts(products: Product[], cb: () => void, errorCb: () => void): void +export declare function getProducts(cb: (products: Product[]) => void): void \ No newline at end of file diff --git a/types/test/shopping-cart/app.ts b/types/test/shopping-cart/app.ts new file mode 100644 index 000000000..0bb4a15d0 --- /dev/null +++ b/types/test/shopping-cart/app.ts @@ -0,0 +1,40 @@ +import Vue from 'vue' +import { cartHelpers } from './store/modules/cart' +import store, { rootHelpers } from './store' + +new Vue({ + store, + + computed: { + ...rootHelpers.mapState(['cart']), + ...cartHelpers.mapState({ + test: (state, getters) => { + state.added + getters.cartProducts + } + }), + ...cartHelpers.mapState({ + items: 'added' + }), + ...cartHelpers.mapGetters(['checkoutStatus']) + }, + + methods: { + ...cartHelpers.mapMutations(['addToCart']), + ...cartHelpers.mapActions(['checkout']) + }, + + created () { + this.cart + this.test + this.items + this.checkoutStatus + this.addToCart({ id: 123 }) + this.checkout([{ + id: 123, + price: 3000, + title: 'test', + inventory: 3 + }]) + } +}) diff --git a/types/test/shopping-cart/store/index.ts b/types/test/shopping-cart/store/index.ts new file mode 100644 index 000000000..d86973f58 --- /dev/null +++ b/types/test/shopping-cart/store/index.ts @@ -0,0 +1,20 @@ +import Vue from 'vue' +import Vuex, { createNamespacedHelpers } from '../../../index' +import { cart, CartState } from './modules/cart' +import { products, ProductsState } from './modules/products' + +Vue.use(Vuex) + +export interface RootState { + cart: CartState + products: ProductsState +} + +export const rootHelpers = createNamespacedHelpers() + +export default new Vuex.Store({ + modules: { + cart, + products + } +}) diff --git a/types/test/shopping-cart/store/modules/cart.ts b/types/test/shopping-cart/store/modules/cart.ts new file mode 100644 index 000000000..ff259c674 --- /dev/null +++ b/types/test/shopping-cart/store/modules/cart.ts @@ -0,0 +1,116 @@ +import { createNamespacedHelpers, DefineModule } from '../../../../index' +import * as shop from '../../api/shop' +import { Product } from './products' +import { RootState } from '../' + +export interface AddedItem { + id: number + quantity: number +} + +export type CheckoutStatus = 'successful' | 'failed' | null + +export interface CartState { + added: AddedItem[] + checkoutStatus: CheckoutStatus +} + +export interface CartGetters { + checkoutStatus: CheckoutStatus + cartProducts: { + title: string + price: number + quantity: number + }[] +} + +export interface CartMutations { + addToCart: { + id: number + }, + checkoutRequest: undefined, + checkoutSuccess: undefined, + checkoutFailure: { + savedCartItems: AddedItem[] + } +} + +export interface CartActions { + checkout: Product[] + addToCart: Product +} + +export const cartHelpers = createNamespacedHelpers('cart') + +export const cart: DefineModule = { + namespaced: true, + + state: { + added: [], + checkoutStatus: null + }, + + getters: { + checkoutStatus: state => state.checkoutStatus, + + cartProducts (state, getters, rootState, g) { + return state.added.map(({ id, quantity }) => { + const product = rootState.products.all.find(p => p.id === id)! + return { + title: product.title, + price: product.price, + quantity + } + }) + } + }, + + actions: { + checkout ({ commit, state }, products) { + const savedCartItems = [...state.added] + commit('checkoutRequest', undefined) + shop.buyProducts( + products, + () => commit('checkoutSuccess', undefined), + () => commit('checkoutFailure', { savedCartItems }) + ) + }, + + addToCart ({ commit }, product) { + if (product.inventory > 0) { + commit('addToCart', { + id: product.id + }) + } + } + }, + + mutations: { + addToCart (state, { id }) { + state.checkoutStatus = null + const record = state.added.find(p => p.id === id) + if (!record) { + state.added.push({ + id, + quantity: 1 + }) + } else { + record.quantity++ + } + }, + + checkoutRequest (state) { + state.added = [] + state.checkoutStatus = null + }, + + checkoutSuccess (state) { + state.checkoutStatus = 'successful' + }, + + checkoutFailure (state, { savedCartItems }) { + state.added = savedCartItems + state.checkoutStatus = 'failed' + } + } +} diff --git a/types/test/shopping-cart/store/modules/products.ts b/types/test/shopping-cart/store/modules/products.ts new file mode 100644 index 000000000..8eb28fe4d --- /dev/null +++ b/types/test/shopping-cart/store/modules/products.ts @@ -0,0 +1,62 @@ +import { createNamespacedHelpers, DefineModule } from '../../../../index' +import * as shop from '../../api/shop' + +export interface Product { + id: number + title: string + price: number + inventory: number +} + +export interface ProductsState { + all: Product[] +} + +export interface ProductsGetters { + allProducts: Product[] +} + +export interface ProductsActions { + getAllProducts: undefined +} + +export interface ProductsMutations { + receiveProducts: { + products: Product[] + }, + addToCart: { + id: number + } +} + +export const productsHelpers = createNamespacedHelpers('products') + +export const products: DefineModule = { + namespaced: true, + + state: { + all: [] + }, + + getters: { + allProducts: state => state.all + }, + + actions: { + getAllProducts ({ commit }) { + shop.getProducts(products => { + commit('receiveProducts', { products }) + }) + } + }, + + mutations: { + receiveProducts (state, { products }) { + state.all = products + }, + + addToCart (state, { id }) { + state.all.find(p => p.id === id)!.inventory-- + } + } +} diff --git a/types/test/tsconfig.json b/types/test/tsconfig.json index 28f595aca..9f32dd27b 100644 --- a/types/test/tsconfig.json +++ b/types/test/tsconfig.json @@ -13,7 +13,7 @@ "noEmit": true }, "include": [ - "*.ts", + "**/*.ts", "../*.d.ts", "../../dist/logger.d.ts" ] diff --git a/types/utils.d.ts b/types/utils.d.ts new file mode 100644 index 000000000..cb5ccf609 --- /dev/null +++ b/types/utils.d.ts @@ -0,0 +1,96 @@ +import { StrictActionContext, Module } from './index' + +/** + * Type level utility to annotate types of module state/getters/actions/mutations (module assets). + * To use this helper, the user should declare corresponding assets type at first. + * + * A getters type should be an object that the keys indicate getter names + * and its corresponding values indicate return types of the getter. + * + * Actions type and mutations type should be an object that the keys indicate + * action/mutation names as same as the getters type. + * Its values should be declared as payload types of the actions/mutation. + * + * After declare the above types, the user put them on the generic parameters + * of the utility type. Then the real assets object must follow the passed types + * and type inference will work. + * + * The declared types will be used on mapXXX helpers to safely use module assets + * by annotating its types. + */ +export interface DefineModule< + State, + Getters, + Mutations, + Actions, + ExtraGetters = {}, + ExtraMutations = {}, + ExtraActions = {}, + RootState = {}, + RootGetters = {}, + RootMutations = {}, + RootActions = {} +> extends Module { + getters?: DefineGetters + mutations?: DefineMutations + actions?: DefineActions +} + +/** + * Infer getters object type from passed generic types. + * `Getters` is an object type that the keys indicate getter names and + * its corresponding values are return types of the getters. + * `State` is a module state type which is accessible in the getters. + * `ExtraGetters` is like `Getters` type but will be not defined in the infered getters object. + * `RootState` and `RootGetters` are the root module's state and getters type. + */ +export type DefineGetters< + Getters, + State, + ExtraGetters = {}, + RootState = {}, + RootGetters = {} +> = { + [K in keyof Getters]: ( + state: State, + getters: Getters & ExtraGetters, + rootState: RootState, + rootGetters: RootGetters + ) => Getters[K] +} + +/** + * Infer mutations object type from passed generic types. + * `Mutations` is an object type that the keys indicate mutation names and + * its corresponding values are payload types of the mutations. + * `State` is a module state type which will be mutated in the mutations. + */ +export type DefineMutations = { + [K in keyof Mutations]: (state: State, payload: Mutations[K]) => void +} + +/** + * Infer actions object type from passed generic types. + * `Actions` is an object type that the keys indicate action names and + * its corresponding values are payload types of the actions. + * `State`, `Getters`, `Mutations` are module state/getters/mutations type + * which can be accessed in actions. + * `ExtraActions` is like `Actions` type but will be not defined in the infered actions object. + * `RootState`, `RootGetters`, `RootMutations`, `RootActions` are the root module's asset types. + */ +export type DefineActions< + Actions, + State, + Getters, + Mutations, + ExtraActions = {}, + RootState = {}, + RootGetters = {}, + RootMutations = {}, + RootActions = {} +> = { + [K in keyof Actions]: ( + ctx: StrictActionContext, + payload: Actions[K] + ) => Promise | void +}