Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve helper types for more type safety #1121

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0768c80
feat: improve helper types to utilize Vue 2.5 types
ktsn Nov 6, 2017
25775c5
feat(types): [WIP] more strict typed store assets
ktsn Jan 4, 2018
21ee399
Merge branch 'dev' into feat-improve-typing
ktsn Jan 4, 2018
eee1f3c
fix(types): relax map state function type
ktsn Jan 4, 2018
f94cf70
test(types): update namespaced helper type test
ktsn Jan 4, 2018
a5c4e26
feat(types): allow to specify assets types on mapXXX helpers
ktsn Jan 4, 2018
58d28a5
chore(types): add comments for helpers and utilities types
ktsn Jan 4, 2018
0858c6d
fix(types): revert renaming Payload to avoid breaking change
ktsn Jan 9, 2018
1e27c5e
feat(helpers): return root helpers if no namespace is provided to cre…
ktsn Jan 9, 2018
c2068f3
feat(types): add `DefineModule` utility type
ktsn Jan 9, 2018
7abf34f
fix(types): allow to omit payload on mapped methods if it is untyped
ktsn Jan 9, 2018
9564b80
docs(helpers): improve `createNamespacedHelpers` description
ktsn Jan 11, 2018
1aa407f
fix(types): expose DefineGetters/Mutations/Actions type
ktsn Jan 11, 2018
cfb6042
chore: include utils.d.ts for `files` field
ktsn Jan 11, 2018
00360b5
fix(types): make dispatch/commit more type safe in module actions if …
ktsn Jan 17, 2018
9b89ae7
fix(types): remove default type parameters from StrictDispatch/Commit
ktsn Jan 17, 2018
09475d5
refactor: use undefined type to indicate empty payload instead of null
ktsn Jan 17, 2018
8e0c60b
fix(types): fix incorrect type annotation
ktsn Jan 17, 2018
b14662c
fix(types): fix ActionContext type
ktsn Jan 17, 2018
b24d744
fix(types): remove incorrect overload
ktsn Jan 18, 2018
8b6a6f9
refactor(types): just use Dispatch/CommitOptions instead of BaseDispa…
ktsn Jan 18, 2018
9b183f0
docs: add typescript docs
ktsn Jan 22, 2018
715eaad
docs: fix typo in typescript docs
ktsn Jan 22, 2018
fc1f29b
docs: add a note about conditional types
ktsn Jan 25, 2018
c3626f7
Merge branch 'dev' into feat-improve-typing
ktsn Feb 1, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@
- [Form Handling](forms.md)
- [Testing](testing.md)
- [Hot Reloading](hot-reload.md)
- [TypeScript Support](typescript.md)
- [API Reference](api.md)
4 changes: 3 additions & 1 deletion docs/en/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
230 changes: 230 additions & 0 deletions docs/en/typescript.md
Original file line number Diff line number Diff line change
@@ -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<CounterState, CounterGetters, CounterMutations, CounterActions> = {
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<CounterState, CounterGetters, CounterMutations, CounterActions>('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<RootState, RootGetters, RootMutations, RootActions>()
```

## 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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a note telling users this might change in future. microsoft/TypeScript#21316

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍
I'll add it later.


```ts
export interface CounterMutation {
// This indicates the `increment` action does not have a payload
increment: undefined
}

// ...
export const counter: DefineModule<CounterState, CounterGetters, CounterMutations, CounterActions> = {
// ...

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.
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

/**
Expand Down
52 changes: 52 additions & 0 deletions test/unit/helpers.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading