Skip to content
5 minute read

Introducing: TypeScript typegen for XState

Matt Pocock

XState and TypeScript are a match made in heaven. TypeScript gives you type safety, and XState gives you logical safety. Together, they give you confidence that your code will do what you expect.

However, we’ve been hearing from the community for some time that the experience of using TypeScript with XState needed improving.

Today's your lucky day. XState’s TypeScript experience just got an enormous upgrade.

We have brought type generation into XState v4.29.0, and we’re aiming for perfect types on all parts of XState’s API.

We’re releasing typegen today as an opt-in beta. Be sure to check out the known limitations section of the docs.

Getting Started

You can visit the official docs for the full guide, or follow the instructions:

  1. Download and install the VS Code extension.

  2. Open a new file and create a new machine, passing the schema attributes:

import { createMachine } from 'xstate';

const machine = createMachine({
schema: {
context: {} as { value: string },
events: {} as { type: 'FOO'; value: string } | { type: 'BAR' },
},
initial: 'a',
states: {
a: {
on: {
FOO: {
actions: 'consoleLogValue',
target: 'b',
},
},
},
b: {
entry: 'consoleLogValueAgain',
},
},
});
  1. Add tsTypes: {} to the machine and save the file:
const machine = createMachine({
+ tsTypes: {},
schema: {
context: {} as { value: string },
events: {} as { type: "FOO"; value: string } | { type: "BAR" },
},
initial: "a",
states: {
/* ... */
},
});
  1. The extension should automatically add a generic to the machine:
const machine = createMachine({
// This generic just got added!
tsTypes: {} as import('./filename.typegen').Typegen0,
/* ... */
});
  1. Add a second parameter into the createMachine call - this is where you implement the actions, services, guards and delays for the machine.
const machine = createMachine(
{
/* ... */
},
{
actions: {
consoleLogValue: (context, event) => {
// Wow! event is typed to { type: 'FOO' }
console.log(event.value);
},
consoleLogValueAgain: (context, event) => {
// Wow! event is typed to { type: 'FOO' }
console.log(event.value);
},
},
},
);

Typing improvements

Let’s get into the nitty-gritty and show you exactly what’s improved.

Events in machine options

One of the most common pain points we heard from our community was using named actions, services and guards with TypeScript. The main reason is that you needed to write code like this:

createMachine(
{
schema: {
events: {} as { type: 'FOO'; value: string } | { type: 'BAR' },
},
on: {
FOO: {
actions: 'myAction',
},
},
},
{
actions: {
myAction: (context, event) => {
/**
* TS don't know if the event is FOO or BAR,
* so we have to defensively check here
*/
if (event.type === 'FOO') {
/**
* Now we know that event.value
* is present on FOO, because
* we checked
*/
console.log(event.value);
}
},
},
},
);

The VS Code extension statically analyzes your machine, and knows which events lead to which actions.

createMachine(
{
/* config */
},
{
actions: {
myAction: (context, event) => {
/**
* No more defensive check needed! event
* is typed to only the events that cause
* the action
*/
console.log(event.value);
},
},
},
);

This works for actions, services, guards and delays. It even works for entry actions, another big pain point from the community.

We’re hoping this lets you cut hundreds of lines of useless defensive code.

Autocomplete on machine options

Another thing we’ve been hearing from the community is that it’s easy to make typos on machine options.

Now, with typegen, you get autocomplete on all machine options. The following code will error:

createMachine(
{
entry: ['sayHello'],
},
{
actions: {
/*
* This will error, because sayhello does not
* exist in the machine declaration above
*/
sayhello: () => {},
},
},
);

We’ve also made it so any missing machine options will error when you implement them later. So, in a React component:

const machine = createMachine({
entry: ['sayHello'],
});

const App = () => {
/**
* This will error, because you haven't implemented
* sayHello in your actions object
*/
const [state, send] = useMachine(machine);
};

That gives you 100% confidence that your machine has all the things it needs to work.

Typing of promise-services

Using promise-based services might be the single biggest pain point with XState and TypeScript. We used to have an entire troubleshooting section in our docs dedicated to them.

Now, you can strongly type the results of promise-based services. Here’s how:

createMachine(
{
schema: {
/**
* Pass the 'services' attribute to schema,
* with all the names of your services and
* the data they return
*/
services: {} as {
myService: {
// The data that gets returned from the service
data: { id: string };
};
},
},
invoke: {
src: 'myService',
onDone: {
actions: 'consoleLogId',
},
},
},
{
services: {
/**
* This service will now type error if it
* returns anything other than { id: string }
*/
myService: async () => {
return {
id: '1',
};
},
},
actions: {
consoleLogId: (context, event) => {
/**
* This event now knows that it will
* receive a data attribute with { id: string }
*/
console.log(event.data.id);
},
},
},
);

This makes handling data return types in XState intuitive, easy and type-safe.

Acknowledgements

I want to thank my Stately colleague Andarist. His TypeScript wizardry, incredible attention to detail and deep love of open source helped make this possible. We’ve literally been talking about this for 18 months - and it’s finally here.