Overmind is written in Typescript and it is written with a focus on you dedicating as little time as possible to help Typescript understand what your app is all about. Typescript will spend a lot more time helping you. If you are not a Typescript developer Overmind is a really great project to start learning it as you will get the most out of the little typing you have to do.
Configuration
First we need to define the typing of our configuration and there are two approaches to that.
1. Declare module
The most straightforward way to type your application is to use the declare module approach. This will work for most applications, but might make you feel uncomfortable as a hardcore Typescripter. The reason is that we are overriding an internal type, meaning that you can only have one instance of Overmind running inside your application.
import { IConfig } from 'overmind'
const config = {}
declare module 'overmind' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Config extends IConfig<{
state: typeof config.state,
actions: typeof config.actions,
effects: typeof config.effects
}> {}
// Due to circular typing we have to define an
// explicit typing of state, actions and effects since
// TS 3.9
}
Now you can import any type directly from Overmind and it will understand the configuration of your application. Even the operators are typed.
You can also explicitly type your application. This gives more flexibility.
import {
IConfig,
IOnInitialize,
IAction,
IOperator,
IState
} from 'overmind'
export const config = {}
// Due to circular typing we have to define an
// explicit typing of state, actions and effects since
// TS 3.9
export interface Config extends IConfig<{
state: typeof config.state,
actions: typeof config.actions,
effects: typeof config.effects
}> {}
export interface OnInitialize extends IOnInitialize<Config> {}
export interface Action<Input = void, Output = void> extends IAction<Config, Input, Output> {}
export interface AsyncAction<Input = void, Output = void> extends IAction<Config, Input, Promise<Output>> {}
export interface Operator<Input = void, Output = Input> extends IOperator<Config, Input, Output> {}
You only have to set up these types once, where you bring your configuration together. That means if you use multiple namespaced configuration you still only create one set of types, as shown above.
Now you only have to make sure that you import your types from this file, instead of directly from the Overmind package.
The Overmind documentation is written for implicit typing. That means whenever you see a type import directly from the Overmind package, you should rather import from your own defined types.
State
The state you define in Overmind is just an object where you type that object.
type State = {
foo: string
bar: boolean
baz: string[]
user: User
}
export const state: State = {
foo: 'bar',
bar: true,
baz: [],
user: new User()
}
It is important that you use a type and not an interface. This has to do with the way Overmind resolves the state typing.
When writing Typescript you should not use optional values for your state (?), or use undefined in a union type. In a serializable state store world null is the value indicating “there is no value”.
type State = {
// Do not do this
foo?: string
// Do not do this
foo: string | undefined
// Do this
foo: string | null
// Or this, if there always will be a value there
foo: string
}
export const state: State = {
foo: null
}
Getter
type State = {
foo: string
shoutedFoo: string
}
export const state: State = {
foo: 'bar',
get shoutedFoo(this: State) {
return this.foo + '!!!'
}
}
Derived
import { derived } from 'overmind'
type State = {
foo: string
shoutedFoo: string
}
export const state: State = {
foo: 'bar',
shoutedFoo: derived((state: State) => state.foo + '!!!')
}
Note that the type argument you pass is the object the derived is attached to, so with nested derived:
import { derived } from 'overmind'
type State = {
foo: string
nested: {
shoutedFoo: string
}
}
export const state: State = {
foo: 'bar',
nested: {
shoutedFoo: derived((state: State['nested']) => state.foo + '!!!')
}
}
Note that with Explicit Typing you need to also pass the a third argument to the derived function, the Config type created in your main index.ts file.
import { RootState } from 'overmind'
type State = {
foo: string
shoutedFoo: string
}
export const state: State = {
foo: 'bar',
shoutedFoo: derived(
(state: State, rootState: RootState) => state.foo + '!!!'
)
}
Statemachine
A statemachine takes a type of states. A big benefit of this approach is that you can type what state is available in any transition state. So for example, you will only have a user if you are in the authenticated transition state.
Now whenever you access this state you can check the current transition state and doing so get the correct state back:
if (state.state === 'AUTHENTICATED') {
state // Typed with "user"
} else if (state.state ==== 'UNAUTHENTICATED') {
state // Not typed with "user"
}
When doing state transitions in actions your will receive the statemachine as the first argument to both entry and exit callbacks, giving you the correct typing.
export const myAction: Action = ({ state }) => {
state.AUTHENTICATED((current) => {
current.user = {...} // Typed with user
}, (current) => {
delete current.user // Typed with user
})
}
Actions
The action type takes either an input type, an output type, or both.
import { Action } from 'overmind'
export const noArgAction: Action = (context, value) => {
value // this becomes "void"
}
export const argAction: Action<string> = (context, value) => {
value // this becomes "string"
}
export const noArgWithReturnTypeAction: Action<void, string> = (context, value) => {
value // this becomes "void"
return 'foo'
}
export const argWithReturnTypeAction: Action<string, string> = (context, value) => {
value // this becomes "string"
return value + '!!!'
}
You also have an async version of this type. You use this when you want to define an async function, which implicitly returns a promise, or use it on a function that explicitly returns a promise.
import { AsyncAction } from 'overmind'
export const noArgAction: AsyncAction = async (context, value) => {
value // this becomes "void"
}
export const argAction: AsyncAction<string> = async (context, value) => {
value // this becomes "string"
}
export const noArgWithReturnTypeAction: AsyncAction<void, string> = async (context, value) => {
value // this becomes "void"
return 'foo'
} // returns Promise<string>
export const argWithReturnTypeAction: AsyncAction<string, string> = (context, value) => {
value // this becomes "string"
return Promise.resolve(value + '!!!')
} // returns Promise<string>
Effects
There are no Overmind specific types related to effects, you just type them in general.
Operators is like the Action type: it can take an optional input, but it always produces an output. By default the output of an operator is the same as the input.
import { Operator, mutate, filter, map } from 'overmind'
// You do not need to define any types, which means it defaults
// its input and output to "void"
export const changeSomeState: () => Operator = () =>
mutate(function changeSomeState({ state }) {
state.foo = 'bar'
})
// The second type argument is not set, but will default to "User"
// The output is the same as the input
export const filterAwesomeUser: () => Operator<User> = () =>
filter(function filterAwesomeUser(_, user) {
return user.isAwesome
})
// "map" produces a new output so we define that as the second
// type argument
export const toNumber: () => Operator<string, number> = () =>
map(function toNumber(_, value) {
return Number(value)
})
The Operator type is used to type all operators. The type arguments you give to Operator have to match the specific operator you use though. So for example if you type a mutate operator with a different output than the input:
import { Operator, pipe, action } from 'overmind'
import * as o from './operators'
import { User } from './state'
export const clickedUser: Operator<User> = pipe(
o.filterAwesome(),
o.handleAwesomeUser()
)
Now the input is actually okay, because { isAwesome: boolean } matches the User type, but we are also now saying that the type of output will be { isAwesome: boolean }, which does not match the User type required by handleAwesomeUser.
To fix this we again infer the type, but using extends to indicate that we do have a requirement to the type it should pass through: