Static analysis of state machines has enormous potential. In this RFC, I'd like to talk about using a CLI tool to generate perfect Typescript types by analysing XState machines in your code.
Typescript with XState is currently imperfect because of XState's innate complexity. The goal is to:
- Get perfect typing of any
MachineOptions
types passed intoMachine
,interpret
,useMachine
etc. This includes typings of events, services, actions, guards and activities based on their usage in the machine. - Get autocomplete on the
matches
function in interpreted state nodes, to allowstate.matches('even.deep.nested.states')
to be type-checkable.
This CLI could, in the future, be tooled to achieve some other stretch goals:
- Autocomplete for states within state machines
- Analysis of state machine paths so that they error in the IDE, not at runtime
- Compile only the features of XState you use to save on bundle size.
Running a CLI gives us many, many more options heading into the future.
We want to get your thoughts on how best to implement the types the codegen tool generates, or if there are any alternative approaches we could take - such as VSCode intellisense or others. We are open to any and all ideas.
This is how the xstate-codegen
npm package works currently.
- Run
xstate-codegen "**/*.machine.ts"
to watch your files. - You create a file with a
.machine.ts
extension. - The CLI creates a sibling file, with a
.machine.typed.ts
extension. - This file creates a custom function to interpret the Machine, depending on configuration passed to the CLI. For now, it only creates a
useMachine
hook for React users. This could work for other contexts too.
Try it out in this npm package: https://www.npmjs.com/package/xstate-codegen
The user must use the codegen-generated functions, and pass in the same machine. This is quite a clunky API:
import { useLightMachine } from "./trafficLightMachine.machine.typed";
import { lightMachine } from "./trafficLightMachine.machine";
const [state, send] = useLightMachine(lightMachine, {
// options
});
Good typings, without overloads.
Clunky API compared with other approaches.
Users would need to configure per project which outputs they'd need - useMachine for React projects, a custom interpret function for others. The tool should work with minimal config.
@Andarist pointed out this could work by scanning the package.json, which would remove this con.
- Run
xstate-codegen "**/*.machine.ts"
to watch your files. - The CLI creates a single file,
xstate-codegen-env.d.ts
at the root of your project. - For each machine in your project, it creates a
<MachineName>StateMachine
type which can be exported and assigned to your machine:
import { Machine, TrafficLightStateMachine } from "xstate";
const trafficLightMachine: TrafficLightStateMachine = Machine({
// machine config
});
- The global
.d.ts
overridesinterpret
,useMachine
etc so that you can use the machine as you would usually:
import { useMachine } from "@xstate/react";
const [state, send] = useMachine(trafficLightMachine, {
// options
});
The user must pull in and assign the generated type to their machine.
This could also be automated on file save via a codemod.
Typings are tied to the machine, which is much better than tying them to the implementation of the machine (i.e. useMachine
, interpret
etc.). They'll get the compiled types anywhere they use the machine, instead of only in codegen-generated functions.
This approachs asks the least of users - a single import change and you're good to go.
This approach, currently, has the weakest typing. This is because the typings on useMachine
, interpret
is done by overloads. This renders the typing of machine options next to useless. If the user passes in an incorrect options object, the types fall back to the previous overload, and no error is shown. This is not currently type safe.
There is more exploratory work to be done on the above to see if any internal changes within XState could be made to facilitate this. I have some ideas which I haven't yet tried.
This works the same as 2, but instead of using useMachine
to consume your machine, you would use useCompiledMachine
.
import { useCompiledMachine } from "@xstate/react";
const [state, send] = useCompiledMachine(trafficLightMachine, {
// options
});
Instead of interpret
, you would use interpretCompiled
or similar.
import { interpretCompiled } from "xstate";
const service = interpretCompiled(trafficLightMachine, {
// options
});
XState would require an update to alias interpretCompiled
to interpret
.
Same pro's as 2, with the added benefit of strong typings without any overloads needed.
Requires more steps from users to make work.
This has no example output as of yet.
This approach would entirely re-implement the typings of XState in a node_modules
folder, @xstate/compiled
. This would allow for maximum customisation of the types by the codegen tool.
It could require the same import syntax as 2, depending on how the internal types are handled:
import { Machine, TrafficLightStateMachine } from "@xstate/compiled";
const trafficLightMachine: TrafficLightStateMachine = Machine({
// machine config
});
To use useMachine
, users would import from @xsate/compiled/react
:
import { useMachine } from "@xstate/compiled/react";
const [state, send] = useMachine(trafficLightMachine, {
// options
});
As much control as we like over the typings, without the potential danger of using overloads.
A small change for users to make, which can also be opted into gradually if required.
Potentially a more complex implementation for the codegen tool, but this is not necessarily a con for the end user.
I've been trying to implement 2a by adding a distinguishing
_isGenerated
property to theStateNode
in XState core. This is working forinterpret
, but not foruseMachine
. It feels a little hacky, too - and I'd need someone from core to take a look to see if I'm on the right track.