vuex-typekit
TypeScript icon, indicating that this package has built-in type declarations

2.1.7 • Public • Published

vuex-typekit

A set of handy types and utility functions for creating strongly typed Vuex modules.

build coverage

The Problem

Vuex, along with many JavaScript implementations of the flux pattern, throws away a lot of useful type information. A Store only keeps the type of its state and you don't get any parameter type information when calling commit or dispatch. However, by adding some extra types and utility functions, we can write types for our modules that help with both writing the modules and using them.

TL/DR: Why?

Wouldn't it be great if we could use vuex and get the benefits of static typing?

Let's say we have a mutation defined like

SET_VALUE: (state: { value: string }, payload: { value: string }) =>
    (state.value = payload.value)

When using our vuex modules it would really be beneficial to be able to catch some errors early:

If we forget to pass a payload:

valueAction: ({ commit }, payload: { value: string }) => {
    commit('SET_VALUE') // no payload!
}

If we pass the incorrect type of payload:

valueAction: ({ commit }, payload: { value: string }) => {
    commit('SET_VALUE', payload.value) // incorrect payload!
}

Or even if we try to call a mutation (or action) that doesn't exist:

valueAction: ({ commit }, payload: { value: string }) => {
    commit('SETVALUE', payload.value) // wrong mutation type!
}

This applies to both to the internal usage of our models, and the external usage in our components. We can't normally catch these kinds of errors at build time, but vuex-typekit makes this possible!

How To

To take advantage of the utility functions, it is first necessary to declare interfaces for your module's Mutations, Actions and Getters (whichever are necessary for your module). This might seem laborious at first, but it makes implementing them a bit easier and we'll use them later to get type safe access to the store. It also makes it easier to extend module interfaces.

Important to note: DO NOT extend Vuex's MutationTree, ActionTree and GetterTree interfaces, the index signature of those interfaces will weaken the typing. Instead, using the utility types in the example below will create module component trees that implicitly satisfies these interfaces.

export type Todo = {
    done: boolean
    text: string
}
export interface TodoState {
    todos: Todo[]
    filter: {
        done: boolean | undefined
        text: string | undefined
    }
}

export interface TodoMutations {
    ADD_TODO: MutationType<TodoState> // provide state type and optionally payload type
    SET_DONE: MutationType<TodoState, { index: number; done: boolean }>
    SET_TEXT: MutationType<TodoState, { index: number; text: string }>
    REMOVE_TODO: MutationType<TodoState, { index: number }>
    SET_FILTER_DONE: MutationType<TodoState, { done: boolean | undefined }>
    SET_FILTER_TEXT: MutationType<TodoState, { text: string | undefined }>
}

export interface TodoGetters {
    filtered: GetterType<Todo[], TodoState> // provide return type and state, optionally the root state and root getters
    doneCount: GetterType<number, TodoState>
    notDoneCount: GetterType<number, TodoState>
}

export interface TodoActions {
    clearDone: ActionType<TodoState> // provide state type and optionally payload type
    removeTodo: ActionType<TodoState, { index: number }>
    setDone: ActionType<TodoState, { index: number; done: boolean }>
    setText: ActionType<TodoState, { index: number; text: string }>
}

The MutationType and ActionType types let you declare your mutations and actions based on state as well as the type of the payload. GetterType types a getter to its return type and state, and optionally the types of getters, root state and root getters.

Now we can implement the module with createModule helper function:

export default new Store<TodoState>(
    createModule<TodoState, TodoMutations, TodoActions, TodoGetters>({
        state: () => ({
            todos: [],
            filter: {
                done: undefined,
                text: undefined,
            },
        }),
        mutations: {
            ADD_TODO: state => state.todos.push({ done: false, text: '' }),
            REMOVE_TODO: (state, { index }) => state.todos.splice(index, 1),
            SET_DONE: (state, { index, done }) => {
                state.todos[index].done = done
            },
            SET_TEXT: (state, { index, text }) => {
                state.todos[index].text = text
            },
            SET_FILTER_DONE: (state, { done }) => (state.filter.done = done),
            SET_FILTER_TEXT: (state, { text }) => (state.filter.text = text),
        },
        getters: {
            filtered: state =>
                state.todos.filter(
                    todo =>
                        (state.filter.done === undefined ||
                            state.filter.done === todo.done) &&
                        (state.filter.text === undefined ||
                            todo.text.includes(state.filter.text))
                ),
            doneCount: state => state.todos.filter(todo => todo.done).length,
            notDoneCount: (state, getters) =>
                state.todos.length - getters.doneCount,
        },
        actions: {
            clearDone: ({ state, commit }) => {
                state.todos
                    .map(({ done }, index) => ({ index, done }))
                    .filter(({ done }) => done)
                    .map(({ index }) => index)
                    .sort()
                    .reverse()
                    .forEach(index => commit.typed('REMOVE_TODO', { index }))
            },
            removeTodo: ({ state, getters, commit }, { index }) => {
                const todo = getters.filtered[index]
                const idx = state.todos.indexOf(todo)
                commit.typed('REMOVE_TODO', { index: idx })
            },
            setDone: ({ state, commit, getters }, { index, done }) => {
                const todo = getters.filtered[index]
                const realIndex = state.todos.indexOf(todo)
                commit.typed('SET_DONE', { index: realIndex, done })
            },
            setText: ({ state, commit, getters }, { index, text }) => {
                const todo = getters.filtered[index]
                const realIndex = state.todos.indexOf(todo)
                commit.typed('SET_TEXT', { index: realIndex, text })
            },
        },
    })
)

createModule takes all of our interfaces and requires implementations for each mutation/action/getter. The payloads of mutations and actions are all inferred from the interfaces, so they do not need to be explicitly declared again.

Implicitly Typed Mutations and Actions

A couple things to note here: for all of our mutations and actions, we don't need to manually annotate the types of our payloads (unless we really want to). They're already defined in our interfaces and carried over.

The Typed Action Context

We have a special ActionContext in our actions when we use createModule (or createActions). We can call commit and dispatch like normal, but we now have access special typed versions via commit.typed and dispatch.typed. The syntax for these is the same, but only valid mutation/action names (from our interfaces) are allowed, and they require the correct payload. Furthermore, we can also gain typed access to other modules by calling (dispatch|commit).root and (dispatch|commit).sub, to reach out to the root module, or into sub modules (respectively). We can call these like dispatch.sub<SubActions>('sub').typed('someSubAction'), where SubActions is an interface for a sub module locally namespaced as 'sub', which has an action called 'someSubAction'.

Hooks for composition-api

Use hooks inside composition-api components. Hook functions are optional and require @vue/composition-api to be installed. These hooks accept a namespace as either a string or a ref to a string. By passing a ref, a single component can switch between more than one namespaced module that implements the same module interface.

// options must be passed in to use the hooks. useStore can be either a regular function that
// returns the store instance, or a hook function using the provide/inject pattern
Vue.use(VuexTypekit, {
    useStore: () => store, // return store
})
export default defineComponent({
    setup: () => {
        return {
            ...useState<TodoState>(/* namespace?: string | Ref<string> */).with(
                'filter',
                'todos'
            ),
            ...useGetters<
                TodoGetters
            >(/* namespace?: string | Ref<string> */).with(
                'doneCount',
                'filtered',
                'notDoneCount'
            ),
            ...useMutataions<
                TodoMutations
            >(/* namespace?: string | Ref<string> */).with(
                'ADD_TODO',
                'REMOVE_TODO'
            ),
            ...useActions<
                TodoActions
            >(/* namespace?: string | Ref<string> */).with(
                'setDone',
                'setText',
                'clearDone'
            ),
        }
    },
})

Typed mapping helpers

For classic options api components, we can use map functions to map a module just like the vuex helpers. Instead of using the mapXXX functions provided by Vuex, we can use mapTypedXXX provided by this module.

export default Vue.extend({
    template,
    computed: {
        ...mapTypedState<TodoState>(/* namespace?: string */).to(
            'todos',
            'filter'
        ),
        ...mapTypedGetters<TodoGetters>(/* namespace?: string */).to(
            'doneCount',
            'notDoneCount',
            'filtered'
        ),
    },
    methods: {
        ...mapTypedMutations<TodoMutations>(/* namespace?: string */).to(
            'ADD_TODO',
            'REMOVE_TODO'
        ),
        ...mapTypedActions<TodoActions>(/* namespace?: string */).to(
            'setDone',
            'setText',
            'clearDone'
        ),
    },
})

The mapTypedXXX functions, as shown above, have a slightly different syntax from vuex's helpers. We pass in a type argument (and optionally pass a namespace). Then chain a call to a to method, which accepts a list of typed keys. The result is fully typed as well, which means the all the keys we choose to map are known at design time, so they can even be inferred by developer tools like Vetur inside your component templates.

Optionally we can rename the keys returned from the map functions, similar to passing an object to the vuex helper functions. This sort of call looks like this:

mapTypedMutations<TodoMutations>()
    .map('ADD_TODO', 'REMOVE_TODO')
    .to(({ ADD_TODO, REMOVE_TODO }) => ({
        addTodo: ADD_TODO,
        removeTodo: REMOVE_TODO,
    }))

This lets use rename to anything we want, such as for avoid name collisions, renaming SNAKE_CASED names to camelCase, etc.

VM Instance Methods

Typed access to the store through the view model instance is available as well, just like how you can access this.$store.

import Vue from 'vue'
import VuexTypekit from 'vuex-typekit'

Vue.use(VuexTypekit) // <-- required for vue instance methods below

export default Vue.extend({
    computed: {
        todos() {
            return this.$state<TodoState>(/* namespace?: string */).get('todos')
        },
        filter() {
            return this.$state<TodoState>(/* namespace?: string */).get(
                'filter'
            )
        },
        filtered() {
            return this.$getters<TodoGetters>(/* namespace?: string */).get(
                'filtered'
            )
        },
        doneCount() {
            return this.$getters<TodoGetters>(/* namespace?: string */).get(
                'doneCount'
            )
        },
        notDoneCount() {
            return this.$getters<TodoGetters>(/* namespace?: string */).get(
                'notDoneCount'
            )
        },
    },
    methods: {
        ADD_TODO() {
            this.$mutations<TodoMutations>(/* namespace?: string */).commit(
                'ADD_TODO'
            )
        },
        REMOVE_TODO(payload: { index: number }) {
            this.$mutations<TodoMutations>(/* namespace?: string */).commit(
                'REMOVE_TODO',
                payload
            )
        },
        clearDone() {
            this.$actions<TodoActions>(/* namespace?: string */).dispatch(
                'clearDone'
            )
        },
        setText(payload: { text: string; index: number }) {
            this.$actions<TodoActions>(/* namespace?: string */).dispatch(
                'setText',
                payload
            )
        },
        setDone(payload: { done: boolean; index: number }) {
            this.$actions<TodoActions>(/* namespace?: string */).dispatch(
                'setDone',
                payload
            )
        },
    },
})

This example simply replaces the mapping functions with the same result using the instance methods. Just like in all the other examples, keys, payloads and results are fully typed based on the interfaces passed into them. In the simplest use, this would allow you to map some module properties to different names (in case of name collision). But you could also use state/mutations/actions/etc in any method or computed property, again with strong typing.

Readme

Keywords

Package Sidebar

Install

npm i vuex-typekit

Weekly Downloads

27

Version

2.1.7

License

MIT

Unpacked Size

58.5 kB

Total Files

40

Last publish

Collaborators

  • kbitt