feat(types): Add optional stronger typings #2053
Open
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This PR will include types from my
vuex-strong
project into the core. For now I consider this pull request work in progress but it is quite feature complete and I won't be able to achieve much more without feedback.Fixes #1831
Rationale
Vuex includes types for typescript with official releases. This types are really loose and they do only basic type checking, with some things being just impossible to verify by the typescript compiler - for example payloads are defined always as
any
. Vuex utilizes dynamic nature of JS and until typescript 4.1, which introduced template literal types that allows modules to be correctly handled.Important design decisions
There are few important design decisions that were made, but most of them could be easily changed if needed. This types were made from scratch and are not compatible with current ones. I personally don't think that it's possible to create types that are compatible with previous ones. As this types are not compatible a lot more strict than current ones, they should be optional. For now I think that it will be best to make them opt-in so anyone can use it with simple alternation
tsconfig.json
.By convention I prefixed all exported types with
Vuex
, so instead ofModule
there isVuexModule
etc. This was done to reduce confusion and possible collisions with names when importing. I don't think that this is necessary and it's possible to remove these prefixes, but in my opinion they make things more clear in the code as it's clear what is part of vuex and what is not.Usage
The core idea for this set of types can be simply described as
type first
. You should start designing your store from contract, and your store should implement this contract - not the other way around. As from this package point of view the store is just a vuex module with extra stuff, and I will use wordstore
to refer to both store and module.For example, simple counter store could be described as follows:
Modules -
VuexModule
Modules are arguably the most important aspect of vuex, the store itself can be thought of as main module with some extra configurations - and this is in fact how store definition is typed by this project
Namespaced modules differs in behavior from non-namespaced (global) modules - therefore there are in fact two different types:
GlobalVuexModule
andNamespacedVuexModule
.We can use both types to properly define required module contract
Modules can have sub-modules, that are described by the
VuexModulesTree
- basically every module can be sub-module of any other module as long as it does not create circular reference.All properties like mutations, actions and getters will be correctly namespaced based on the sub-module kind.
State
State is the most straightforward aspect of module - it just represents data held in the store and basically can be any of type.
Full state
However, full state also includes state of the modules, full state of the module can be obtained using
VuexState<TModule>
State provider
State can be provider by value or by factory, therefore we require to type that accordingly. To simplify things,
VuexStateProvider<TState>
helper can be utilized:To extract state from the provider,
VuexExtractState<TProvider>
can be used:Mutations
Of course the state have to change somehow - that's what mutations do. All mutations available in given module are described by the
VuexMutationsTree
type. Mutation tree is just an keyed storage for mutation handlers described byVuexMutationHandler
:Example of mutation tree definition:
If mutation requires access to the whole store, it can be typed using third generic argument of
VuexMutationHandler
:Be careful when using this construct though, it is circular reference and can create infinite recursion in typescript if not used with caution.
It is common (and in my opinion recommended) for mutation types to be defined as code constants - and in typescript it's possible to use
enums
for that purpose:Then values of this enum can be used to key mutations tree type:
Implementation of mutations can be done simply by implementing the tree:
Such well-defined mutations are then available from
commit
method of the store:There also exists few utility types:
VuexMutationTypes<TModule>
- defines all possible mutation types, e.g.VuexMutationTypes<MyStore> = "foo/added" | "foo/removed" | /* ... */
VuexMutations<TModule>
- defines all possible mutations with payloads, e.g.VuexMutations<MyStore> = { type: "foo/added", payload: string } | { type: foo/removed", payload: number } | /* ... */
VuexMutationPayload<TModule, TMutation>
- extracts mutation payload by name, e.g.VuexMutationPayload<MyStore, "foo/added"> = string
Actions
In principle, actions are very similar to mutations - the main difference is that they can be asynchronous and have return types. Actions make changes in the state by committing mutations, they can also dispatch another actions if needed. Oh, and in case of namespaced modules they are scoped to module.
Action tree is described by the
VuexActionsTree
type, which itself (same as mutations tree case) is just an collection of action handles:Because actions can access basically everything from the module itself they need to have module back referenced - this can be a little bit tricky sometimes and can cause infinite recursion if not used with caution. However it should be fine in most cases.
Let's reiterate on the
FooModule
example to better see how actions can be defined:Of course just like in mutations it is possible to use alternative function based syntax for handler definition - you will however need to make it compatible with requirements by yourself. Enums are also not required but I will stick with them in the rest of examples as I personally think that this is the most correct way.
And again, just like mutations implementation can be done simply by implementing created action tree type:
Context (defined by
VuexActionContext<TModule, TRoot>
type) that is passed to action handler should be typed correctly and scoped to passed module:Again there also exists few utility types:
VuexActionTypes<TModule>
- defines all possible action types, e.g.VuexActionTypes<MyStore> = "foo/load" | "foo/refresh" | /* ... */
VuexActions<TModule>
- defines all possible actions with payloads, e.g.VuexActions<MyStore> = { type: "foo/load", payload: string[] } | { type: "foo/refresh" } | /* ... */
VuexActionPayload<TModule, TAction>
- extracts action payload by name, e.g.VuexMutationPayload<MyStore, "foo/load"> = string[]
VuexActionResult<TModule, TAction>
- extracts action result by name, e.g.VuexMutationPayload<MyStore, "foo/load"> = Promise<string[]>
Getters
Getters are, to put simply, just computer properties of state accessible by some key. Getters tree is described by the
VuexGettersTree
type, which itself is just an collection of getters:Definition and implementation of getter tree is simple:
And getters can be then accessed from the store, and the result will have correct type:
As always there exists few utility types:
VuexGetterNames<TModule>
- defines all possible getter names, e.g.VuexGetterNames<MyStore> = "foo/first" | "foo/firstCapitalized" | /* ... */
VuexGetters<TModule>
- defines all possible getters with results, e.g.VuexGetters<MyStore> = { first: string, firstCapitalized: string }
Store
As was previously said, the store definition is just a global module with extra properties:
The
createStore
function takesVuexStoreDefinition
as an argument, and creates store instance from it. The store instance is described by theVuexStore<TDefinition extends VuexStoreDefinition>
type.Store instance should be fully typed and be mostly type-safe. It means that payloads of actions and mutations would be checked, action results will be known and could be checked, values from getters will be properly typed and so on. You also won't be able to commit/dispatch non-existent mutations/actions.
In theory store definition could be inferred from the argument of
createStore
but it's highly unrecommended as the contract will be basically guessed (which means that it can be guessed wrongly) based on definition and not checked - it should provide some useful features when using store instance though.It is also possible to turn off type safety by explicitly providing
any
type tocreateStore
, which could be useful when dealing with highly dynamic stores.Component Binding Helpers
Vuex provides handy Component Binding Helpers that can be used for easily mapping state, getters, mutations and actions into component. Those helpers are also strictly typed. Unfortunately, until typescript has proper partial infering support (see issue #10571) it's not possible to use syntax like
mapState<MyStore>(...)
. By default helpers are bound non-type-safely to any module, and to enable type safety it's required to re-export them with proper type applied. This could be done in the same file as store definition, for example:Then you should be able to import those wrappers with typing applied and use type-safe helpers.
Gotchas and caveats
Full Example
This example is taken from the test/basic.ts file and could be interactively tested using vscode or other editor with decent support of typescript language server.
Progress
VuexGlobalModule<TState, TMutations = {}, TActions = {}, TModules = {}, TGetters = {}>
VuexNamespacedModule<TState, TMutations = {}, TActions = {}, TModules = {}, TGetters = {}>
TState
VuexState<TModule>
VuexOwnState<TModule>
TMutations extends VuexMutationsTree
VuexMutationsTree
??VuexMutations<TModule>
VuexOwnMutations<TModule>
VuexMutationHandler<TState, TPayload = never, TStore = never>
this
in handler (store backref)VuexMutationPayload<TModule, TMutation>
VuexCommit<TModule>
VuexMutations<TModule>
VuexCommitOptions
{ root: true }
TActions extends VuexActionsTree
VuexActionsTree
??VuexActions<TModule>
VuexOwnActions<TModule>
VuexActionHandler<TModule, TPayload = never, TResult = Promise<void>>
VuexActionContext<TModule, TStoreDefinition = any>
this
in handler (store backref)VuexDispatch<TModule>
VuexActionPayload<TModule, TAction>
VuexActionResult<TModule, TAction>
VuexAction<TModule>
VuexDispatchOptions
{ root: true }
TGetters extends VuexGettersTree
VuexGettersTree
??VuexGetters<TModule>
VuexOwnGetters<TModule>
VuexGetter<TModule, TResult>
VuexGetterResult<TModule, TGetter>
TModules extends VuexModulesTree
VuexStoreDefinition<TState, TMutations = {}, TActions = {}, TModules = {}, TGetters = {}>
VuexGlobalModule
with additional thingsVuexPlugin<TStoreDefinition>
devtools
, etc.)VuexStore<TStoreDefinition>
VuexStoreDefinition
replaceState
VuexSubscribeOptions
subscribeAction
VuexActionSubscriber<TDefinition>
VuexActionSubscriberCallback<TDefinition>
VuexActionErrorSubscriberCallback<TDefinition>
VuexActionSubscribersObject<TDefinition>
subscribe
VuexMutationSubscriber<TDefinition>
watch
Optionsshould be imported from VueWatchOptions
registerModule
unregisterModule
hasModule
useStore<TKey extends VuexInjectionKey<TStore>, TStore>(key: TKey): VuexStore<TStore>
mapState<TModule>
in form ofVuexMapStateHelper<TModule>
mapMutations<TModule>
in form ofVuexMapMutationsHelper<TModule>
mapGetters<TModule>
in form ofVuexMapGettersHelper<TModule>
mapActions<TModule>
in form ofVuexMapActionsHelper<TModule>
createNamespaceHelpers<TModule>
in form ofVuexCreateNamespacedHelpers<TModule>