├── .gitignore ├── README.md ├── deno.json ├── react ├── mod.ts ├── deno.json ├── useMachine.ts └── useMachine.test.ts ├── core ├── mod.ts ├── deno.json ├── getState.ts ├── createMachine.ts ├── types.ts ├── README.md └── main.test.ts └── deno.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | core/README.md -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace": [ 3 | "./core", 4 | "./react" 5 | ] 6 | } -------------------------------------------------------------------------------- /react/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "@machinist/core"; 2 | export { useMachine } from "./useMachine.ts"; 3 | -------------------------------------------------------------------------------- /core/mod.ts: -------------------------------------------------------------------------------- 1 | export { getState } from "./getState.ts"; 2 | 3 | export { createMachine } from "./createMachine.ts"; 4 | 5 | export type { DeclareMachine, Machine, State } from "./types.ts"; 6 | -------------------------------------------------------------------------------- /core/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@machinist/core", 3 | "version": "0.0.8", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "publish": { 7 | "exclude": [ 8 | "./**/*.test.ts" 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /core/getState.ts: -------------------------------------------------------------------------------- 1 | import type { State } from "./types.ts"; 2 | 3 | /** 4 | * Returns the current state of a state machine instance. 5 | */ 6 | 7 | export const getState = (instance: T): State => { 8 | return (instance as T & { _getState: () => State })._getState(); 9 | }; 10 | -------------------------------------------------------------------------------- /react/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@machinist/react", 3 | "version": "0.0.8", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "publish": { 7 | "exclude": [ 8 | "./**/*.test.ts" 9 | ] 10 | }, 11 | "imports": { 12 | "@testing-library/react-hooks": "npm:@testing-library/react-hooks@^8.0.1", 13 | "@types/react": "npm:@types/react@^19.1.8", 14 | "react": "npm:react@^19.1.0", 15 | "react-test-renderer": "npm:react-test-renderer@^19.1.0" 16 | } 17 | } -------------------------------------------------------------------------------- /react/useMachine.ts: -------------------------------------------------------------------------------- 1 | import { createMachine } from "@machinist/core"; 2 | import type { Machine, State } from "@machinist/core"; 3 | import { useState } from "react"; 4 | import type { InferUnion } from "../core/types.ts"; 5 | 6 | /** 7 | * Takes a machine implementation and an initial state, and returns a reactive instance.\ 8 | * Transitions will trigger re-renders in the consuming component. 9 | */ 10 | export const useMachine = ( 11 | machine: Machine, 12 | initialState: State>, 13 | ): T => { 14 | const [instance, setInstance] = useState(() => { 15 | const reactiveMachine = createMachine({ 16 | ...machine, 17 | onTransition: (prevState, newState) => { 18 | setInstance(reactiveMachine.new(newState) as T); 19 | machine.onTransition?.(prevState, newState); 20 | }, 21 | }); 22 | return reactiveMachine.new(initialState) as T; 23 | }); 24 | 25 | return instance; 26 | }; 27 | -------------------------------------------------------------------------------- /react/useMachine.test.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, getState } from "@machinist/core"; 2 | import { act, renderHook } from "@testing-library/react-hooks"; 3 | import { assertEquals } from "jsr:@std/assert"; 4 | 5 | import { useMachine } from "./useMachine.ts"; 6 | 7 | type Active = { 8 | status: "active"; 9 | 10 | lock(reason: string): Locked; 11 | delete(reason: string): Deleted; 12 | }; 13 | 14 | type Locked = { 15 | status: "locked"; 16 | reason: string; 17 | 18 | unlock(): Active; 19 | delete(reason: string): Deleted; 20 | }; 21 | 22 | type Deleted = { 23 | status: "deleted"; 24 | reason: string; 25 | }; 26 | 27 | type User = Active | Locked | Deleted; 28 | 29 | Deno.test("useMachine", () => { 30 | const userMachine = createMachine({ 31 | transitions: { 32 | lock: (prev, reason) => ({ 33 | ...prev, 34 | status: "locked", 35 | reason, 36 | }), 37 | delete: (prev, reason) => ({ 38 | ...prev, 39 | status: "deleted", 40 | reason, 41 | }), 42 | unlock: (prev) => ({ 43 | ...prev, 44 | status: "active", 45 | }), 46 | }, 47 | }); 48 | 49 | const { result } = renderHook(() => 50 | useMachine(userMachine, { status: "active" }) 51 | ); 52 | const initialUser = result.current; 53 | act(() => { 54 | if (initialUser.status === "active") { 55 | initialUser.lock("some reason"); 56 | } 57 | }); 58 | assertEquals( 59 | getState(result.current), 60 | { reason: "some reason", status: "locked" }, 61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /core/createMachine.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any ban-types 2 | 3 | import type { InferUnion, Machine, MachineImpl } from "../core/types.ts"; 4 | 5 | /** 6 | * Implements the transitions and methods of a state machine.\ 7 | * Takes a state machine declaration as type parameter. 8 | */ 9 | export const createMachine = ( 10 | implementation: MachineImpl>, 11 | ): Machine => { 12 | const _new = (state: any) => { 13 | const wrappedTransitions = Object.entries( 14 | implementation.transitions, 15 | ).reduce< 16 | Record 17 | >((acc, [name, transition]) => { 18 | acc[name] = (...args: any[]) => { 19 | const newState = transition(state, ...args); 20 | const resolveInstance = (resolvedState: any) => { 21 | implementation.onTransition?.(state, resolvedState); 22 | return _new(resolvedState); 23 | }; 24 | 25 | return newState instanceof Promise 26 | ? newState.then(resolveInstance) 27 | : resolveInstance(newState); 28 | }; 29 | 30 | return acc; 31 | }, {}); 32 | 33 | const wrappedMethods = "methods" in implementation 34 | ? Object.entries(implementation.methods) 35 | .reduce< 36 | Record 37 | >((acc, [name, method]) => { 38 | acc[name] = (...args: any[]) => method(state, ...args); 39 | return acc; 40 | }, {}) 41 | : {}; 42 | 43 | const _getState = () => state; 44 | 45 | return { ...wrappedTransitions, ...wrappedMethods, ...state, _getState }; 46 | }; 47 | 48 | return { ...implementation, new: _new } as Machine; 49 | }; 50 | -------------------------------------------------------------------------------- /core/types.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any ban-types 2 | 3 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 4 | k: infer I, 5 | ) => void ? I 6 | : never; 7 | 8 | type GetParams = T extends { 9 | [K in TFunctionKey]: (...args: infer Params) => any; 10 | } ? [prev: State, ...Params] 11 | : never; 12 | 13 | type GetReturn = T extends { 14 | [K in TFunctionKey]: (...args: any[]) => infer R; 15 | } ? R extends Promise ? Promise> : State 16 | : never; 17 | 18 | export type MachineImpl< 19 | T, 20 | TFunctions = UnionToIntersection>, 21 | TTransitions = Transitions, 22 | TMethods = Omit, 23 | > = 24 | & { 25 | transitions: { 26 | [K in keyof TTransitions]: ( 27 | ...args: GetParams 28 | ) => GetReturn; 29 | }; 30 | } 31 | & (keyof TMethods extends never ? {} : { 32 | methods: { 33 | [K in keyof TMethods]: ( 34 | ...args: GetParams 35 | ) => TMethods[K] extends (...args: any[]) => infer R ? R : never; 36 | }; 37 | }) 38 | & { 39 | onTransition?: (prev: State, next: State) => void; 40 | }; 41 | 42 | type Functions = { 43 | [Key in keyof T as T[Key] extends (...args: any[]) => any ? Key : never]: 44 | T[Key]; 45 | }; 46 | type Transitions = { 47 | [ 48 | Key in keyof TFunctions as TFunctions[Key] extends 49 | (...args: any[]) => T | Promise ? Key 50 | : never 51 | ]: TFunctions[Key]; 52 | }; 53 | 54 | /** 55 | * Extracts the type of the state from the machine, removing transitions and methods. 56 | */ 57 | export type State = { 58 | [Key in keyof T as T[Key] extends Function ? never : Key]: T[Key]; 59 | }; 60 | 61 | type ExtractMember< 62 | T, 63 | U, 64 | Member = T extends any ? (U extends State ? T : never) : never, 65 | > = [Member] extends [never] ? T : Member; 66 | 67 | export type InferUnion = string extends keyof T ? T[string] : T; 68 | 69 | /** 70 | * State machine implementation 71 | */ 72 | export type Machine> = { 73 | new: >( 74 | initialState: TState, 75 | ) => ExtractMember; 76 | } & MachineImpl; 77 | 78 | /** 79 | * Declares a state machine and infers the discriminated union of states. 80 | */ 81 | export type DeclareMachine< 82 | T extends { base?: any; states: any; discriminant: string }, 83 | TRecord = { 84 | [K in keyof T["states"]]: 85 | & T["states"][K] 86 | & { [Discriminant in T["discriminant"]]: K } 87 | & T["base"]; 88 | }, 89 | TUnion = TRecord[keyof TRecord], 90 | > = TRecord & { [union: string]: TUnion }; 91 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "specifiers": { 4 | "jsr:@std/assert@*": "1.0.10", 5 | "jsr:@std/assert@^1.0.10": "1.0.10", 6 | "jsr:@std/datetime@*": "0.225.2", 7 | "jsr:@std/internal@^1.0.5": "1.0.5", 8 | "jsr:@std/testing@*": "1.0.8", 9 | "npm:@testing-library/react-hooks@^8.0.1": "8.0.1_@types+react@19.1.8_react@19.1.0_react-test-renderer@19.1.0__react@19.1.0", 10 | "npm:@types/react@^19.1.8": "19.1.8", 11 | "npm:react-test-renderer@^19.1.0": "19.1.0_react@19.1.0", 12 | "npm:react@^19.1.0": "19.1.0" 13 | }, 14 | "jsr": { 15 | "@std/assert@1.0.10": { 16 | "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", 17 | "dependencies": [ 18 | "jsr:@std/internal" 19 | ] 20 | }, 21 | "@std/datetime@0.225.2": { 22 | "integrity": "45f0100554a912cd65f48089ef0a33aa1eb6ea21f08090840b539ab582827eaa" 23 | }, 24 | "@std/internal@1.0.5": { 25 | "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" 26 | }, 27 | "@std/testing@1.0.8": { 28 | "integrity": "ceef535808fb7568e91b0f8263599bd29b1c5603ffb0377227f00a8ca9fe42a2", 29 | "dependencies": [ 30 | "jsr:@std/assert@^1.0.10" 31 | ] 32 | } 33 | }, 34 | "npm": { 35 | "@babel/runtime@7.27.6": { 36 | "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==" 37 | }, 38 | "@testing-library/react-hooks@8.0.1_@types+react@19.1.8_react@19.1.0_react-test-renderer@19.1.0__react@19.1.0": { 39 | "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", 40 | "dependencies": [ 41 | "@babel/runtime", 42 | "@types/react", 43 | "react", 44 | "react-error-boundary", 45 | "react-test-renderer" 46 | ], 47 | "optionalPeers": [ 48 | "@types/react", 49 | "react-test-renderer" 50 | ] 51 | }, 52 | "@types/react@19.1.8": { 53 | "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", 54 | "dependencies": [ 55 | "csstype" 56 | ] 57 | }, 58 | "csstype@3.1.3": { 59 | "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" 60 | }, 61 | "react-error-boundary@3.1.4_react@19.1.0": { 62 | "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", 63 | "dependencies": [ 64 | "@babel/runtime", 65 | "react" 66 | ] 67 | }, 68 | "react-is@19.1.0": { 69 | "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==" 70 | }, 71 | "react-test-renderer@19.1.0_react@19.1.0": { 72 | "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", 73 | "dependencies": [ 74 | "react", 75 | "react-is", 76 | "scheduler" 77 | ] 78 | }, 79 | "react@19.1.0": { 80 | "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==" 81 | }, 82 | "scheduler@0.26.0": { 83 | "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" 84 | } 85 | }, 86 | "workspace": { 87 | "members": { 88 | "react": { 89 | "dependencies": [ 90 | "npm:@testing-library/react-hooks@^8.0.1", 91 | "npm:@types/react@^19.1.8", 92 | "npm:react-test-renderer@^19.1.0", 93 | "npm:react@^19.1.0" 94 | ] 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 |

Machinist

2 | 3 | ### Type-driven finite state machines 4 | 5 | Describe state machines with types, letting them drive implementation and usage. 6 | 7 | ### Installation 8 | 9 | ```bash 10 | deno add jsr:@machinist/core 11 | pnpm add jsr:@machinist/core 12 | yarn add jsr:@machinist/core 13 | 14 | # npm 15 | npx jsr add @machinist/core 16 | 17 | # bun 18 | bunx jsr add @machinist/core 19 | ``` 20 | 21 | ### Usage 22 | 23 | First describe the machine with its states and transitions at the type level, 24 | using a 25 | [discriminated union](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions): 26 | 27 | ```ts 28 | type BaseUser = { 29 | age: number; 30 | name: string; 31 | }; 32 | 33 | type ActiveUser = BaseUser & { 34 | status: "active"; 35 | 36 | lock(reason: string): LockedUser; 37 | }; 38 | 39 | type LockedUser = BaseUser & { 40 | status: "locked"; 41 | lockReason: string; 42 | 43 | unlock(): ActiveUser; 44 | ban(): BannedUser; 45 | }; 46 | 47 | type BannedUser = BaseUser & { 48 | status: "banned"; 49 | bannedAt: Date; 50 | }; 51 | 52 | type User = ActiveUser | LockedUser | BannedUser; 53 | ``` 54 | 55 | Transitions are methods that return a new state of the machine. Here an `active` 56 | user can only transition to a `locked` state, while a `banned` user is in a 57 | final state meaning it can't transition to any other state. 58 | 59 | Since we're working with a discriminated union we can narrow the type of a user 60 | based on its `status`, and have the compiler only accept valid transitions for 61 | this state: 62 | 63 | ```ts 64 | if (user.status === "active") { 65 | // user has been narrowed, the compiler knows `lock` is available 66 | user.lock("reason"); 67 | } 68 | // else we can't call `lock` 69 | ``` 70 | 71 | > [!IMPORTANT] 72 | > The instances of the machines are **immutable**, transitions return new 73 | > instances and leave the original one unchanged. 74 | > 75 | > ```ts 76 | > const bannedUser = activeUser.lock("reason").ban(); 77 | > // activeUser !== bannedUser 78 | > ``` 79 | > 80 | > Do not mutate the state directly, create dedicated transitions instead (can be 81 | > a self-transition if the type doesn't change). 82 | 83 | ### Implementation 84 | 85 | To implement the transitions call the `createMachine` function with the machine 86 | as type argument: 87 | 88 | ```ts 89 | import { createMachine } from "@machinist/core"; 90 | 91 | const userMachine = createMachine({ 92 | transitions: { 93 | lock: (user, reason) => ({ ...user, status: "locked", lockReason: reason }), 94 | unlock: (user) => ({ ...user, status: "active" }), 95 | ban: (user) => ({ ...user, status: "banned", bannedAt: new Date() }), 96 | }, 97 | }); 98 | ``` 99 | 100 | Transitions take the current state as first parameter, followed by the 101 | parameters declared in the types. They return the new state according to the 102 | destination type of the transition. 103 | 104 | Finally to spawn new instances of the machine call the `new` method with the 105 | initial state: 106 | 107 | ```ts 108 | const activeUser = userMachine.new({ 109 | status: "active", 110 | name: "Alice", 111 | age: 25, 112 | }); 113 | 114 | const lockedUser = userMachine.new({ 115 | status: "locked", 116 | name: "Bob", 117 | age: 30, 118 | lockReason: "reason", 119 | }); 120 | ``` 121 | 122 | # 123 | 124 | Keeping the implementation separate allows the declaration to remain high-level 125 | and readable, without drowning the signal in implementation details. It also 126 | allows for multiple implementations of the same machine declaration.\ 127 | You can still jump between the declaration and the implementations with your 128 | editor's symbols navigation (Go to Type Definition/Go to Implementation). 129 | 130 | #### Methods 131 | 132 | Methods that aren't transitions (that don't transition to a state of the 133 | machine) are implemented under `methods`: 134 | 135 | ```ts 136 | type BannedUser = BaseUser & { 137 | //... 138 | daysSinceBan: () => number; 139 | }; 140 | 141 | const userMachine = createMachine({ 142 | transitions: { 143 | //... 144 | }, 145 | methods: { 146 | daysSinceBan: (user) => 147 | (Date.now() - user.bannedAt.getTime()) / (1000 * 60 * 60 * 24), 148 | }, 149 | }); 150 | 151 | userMachine.new({ 152 | name: "Charlie", 153 | age: 35, 154 | status: "banned", 155 | bannedAt: new Date("2021-01-01"), 156 | }).daysSinceBan(); // 123 157 | ``` 158 | 159 | That means `createMachine` is useful beyond just state machines, and can be used 160 | as a general implementation target just like classes (the main difference being 161 | `this` replaced by the first parameter of the method). 162 | 163 | #### onTransition callback 164 | 165 | With `onTransition` you can listen to every transition happening in the machine, 166 | and run side-effects depending on the previous and new state: 167 | 168 | ```ts 169 | createMachine({ 170 | transitions: { 171 | //... 172 | }, 173 | onTransition: (from, to) => { 174 | console.log(`Transition from ${from.status} to ${to.status}`); 175 | if (from.status === "locked" && to.status === "active") { 176 | console.log( 177 | `User ${to.name} unlocked. Previous reason "${from.lockReason}" does not apply anymore.`, 178 | ); 179 | } 180 | }, 181 | }); 182 | ``` 183 | 184 | ### React 185 | 186 | `@machinist/react` exports everything that's in `core`, plus a `useMachine` 187 | hook.\ 188 | It takes a machine implementation and an initial state, and returns a reactive 189 | instance that will rerender the component on changes. 190 | 191 | ```tsx 192 | import { useMachine } from "@machinist/react"; 193 | import { userMachine } from "./userMachine"; 194 | 195 | const Component = () => { 196 | const user = useMachine(userMachine, initialState); 197 | 198 | return ( 199 | <> 200 |
Name: {user.name}
201 | {user.status === "locked" && ( 202 | 205 | )} 206 | {/* ... */} 207 | 208 | ); 209 | }; 210 | ``` 211 | 212 | It is conceptually similar to `useReducer`, but with the additional benefits of 213 | the compiler checking if the transition is valid for the current state. 214 | 215 | To support additional frameworks PRs are welcome! 216 | 217 | ### Type helper 218 | 219 | Declaring discriminated unions can be a bit verbose and unwieldy: every member 220 | needs to be declared as a separate type, that potentially needs to be exported. 221 | In each one of them the key of the discriminant property has to bear the exact 222 | same name (e.g. `status`). Finally they each need to extend the common base type 223 | (if any), and also not be omitted from the final union. 224 | 225 | The library provides a type helper `DeclareMachine` to simplify this process: 226 | 227 | ```ts 228 | import { createMachine, type DeclareMachine } from "@machinist/core"; 229 | 230 | export type User = DeclareMachine<{ 231 | base: { 232 | name: string; 233 | age: number; 234 | }; 235 | discriminant: "status"; 236 | states: { 237 | active: { 238 | lock(reason: string): User["locked"]; 239 | }; 240 | locked: { 241 | lockReason: string; 242 | 243 | unlock(): User["active"]; 244 | ban(): User["banned"]; 245 | }; 246 | banned: { 247 | bannedAt: Date; 248 | }; 249 | }; 250 | }>; 251 | 252 | const userMachine = createMachine({/* ... */}); 253 | ``` 254 | 255 | For every member it will add the properties from `base`, as well as the 256 | discriminant with the provided name (e.g. locked -> `status: "locked"`).\ 257 | The resulting type is a map that indexes each member by its discriminant (e.g. 258 | `User["locked"]`), while the union itself is indexed under `string` (e.g. 259 | `User[string]`).\ 260 | It has the benefits of only having to declare and export a single type, reducing 261 | boilerplate, and preventing inconsistencies. 262 | 263 | ### FAQ 264 | 265 | #### - Why are transitions immutable? 266 | 267 | Correctly infering the new type of an instance after a transition is easier if 268 | it returns a new instance, rather than mutating the original one.\ 269 | Immutability also makes it easier to historicize and compare previous states 270 | (like done in the `onTransition` callback). 271 | 272 | #### - What's the difference with XState? 273 | 274 | The main difference is that `XState` is event-driven while `Machinist` is not.\ 275 | With `XState` the caller dispatches an event that will be interpreted by the 276 | machine, to potentially trigger a transition. If the machine doesn't define a 277 | transition for the current state and event, then the event is silently dropped. 278 | 279 | On the other hand with `Machinist` the caller directly invokes the transition 280 | like a normal method, and it's up to the same caller to ensure that the machine 281 | is in a valid state before doing so.\ 282 | Thanks to discriminated unions the compiler can automatically narrow the type of 283 | the state inside of the condition, and list every valid transitions for the 284 | current state. That means no phantom events, the type of the new state is 285 | statically known, and the caller is naturally nudged toward also handling the 286 | case where the machine is not in the desired state (e.g. not rendering the ban 287 | button if the user is already in the banned state). 288 | 289 | The other obvious difference is that `Machinist` is a small library exporting 290 | two functions, while `XState` has a much larger API surface, bundle size, and 291 | number of supported features. 292 | -------------------------------------------------------------------------------- /core/main.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertObjectMatch } from "jsr:@std/assert"; 2 | import { assertType, type IsExact } from "jsr:@std/testing/types"; 3 | import { assertSpyCalls, spy } from "jsr:@std/testing/mock"; 4 | import { difference } from "jsr:@std/datetime/difference"; 5 | 6 | import { createMachine } from "./createMachine.ts"; 7 | import type { DeclareMachine, Machine, State } from "./types.ts"; 8 | import { getState } from "./getState.ts"; 9 | 10 | type BaseUser = { 11 | name: string; 12 | age: number; 13 | }; 14 | 15 | type UserPending = BaseUser & { 16 | status: "pending"; 17 | 18 | validate(email: string): UserValidated; 19 | }; 20 | 21 | type UserValidated = BaseUser & { 22 | status: "validated"; 23 | email: string; 24 | 25 | changeEmail(): UserPending; 26 | delete(reason: string): UserDeleted; 27 | lock(days: number): UserLocked; 28 | }; 29 | 30 | type UserLocked = BaseUser & { 31 | status: "locked"; 32 | days: number; 33 | lockStart: Date; 34 | email: string; 35 | 36 | unlock(): UserValidated; 37 | delete(reason: string): UserDeleted; 38 | getRemainingDays(): number; 39 | }; 40 | 41 | type UserDeleted = BaseUser & { 42 | status: "deleted"; 43 | deletionReason: string; 44 | deletionDate: Date; 45 | email: string; 46 | 47 | changeReason(reason: string): UserDeleted; 48 | }; 49 | 50 | type User = UserPending | UserValidated | UserLocked | UserDeleted; 51 | 52 | Deno.test("machine", async (t) => { 53 | const userMachine = createMachine({ 54 | transitions: { 55 | validate: (prev, email) => ({ ...prev, status: "validated", email }), 56 | changeEmail: ({ ...prev }) => ({ ...prev, status: "pending" }), 57 | changeReason: (prev, reason) => ({ ...prev, deletionReason: reason }), 58 | delete: (prev, reason) => ({ 59 | ...prev, 60 | status: "deleted", 61 | deletionReason: reason, 62 | deletionDate: new Date(), 63 | }), 64 | lock: (prev, days) => ({ 65 | ...prev, 66 | status: "locked", 67 | days, 68 | lockStart: new Date(), 69 | }), 70 | unlock: (prev) => ({ ...prev, status: "validated" }), 71 | }, 72 | methods: { 73 | getRemainingDays: (state) => { 74 | const daysSinceLock = difference(state.lockStart, new Date()).days ?? 0; 75 | return Math.max(0, state.days - daysSinceLock); 76 | }, 77 | }, 78 | }); 79 | 80 | await t.step("transitions", () => { 81 | const userPending = userMachine.new({ 82 | name: "John", 83 | age: 32, 84 | status: "pending", 85 | }); 86 | assertType>(true); 87 | assertEquals(getState(userPending), { 88 | age: 32, 89 | name: "John", 90 | status: "pending", 91 | }); 92 | 93 | const userValidated = userPending.validate("john@domain.org"); 94 | assertType>(true); 95 | assertEquals(getState(userValidated), { 96 | age: 32, 97 | email: "john@domain.org", 98 | name: "John", 99 | status: "validated", 100 | }); 101 | 102 | const userPendingAgain = userValidated.changeEmail(); 103 | assertType>(true); 104 | assertObjectMatch( 105 | getState(userPendingAgain), 106 | { 107 | age: 32, 108 | name: "John", 109 | status: "pending", 110 | } satisfies State, 111 | ); 112 | 113 | const userLocked = userValidated.lock(7); 114 | assertType>(true); 115 | assertEquals(getState(userLocked), { 116 | ...getState(userValidated), 117 | status: "locked", 118 | lockStart: userLocked.lockStart, 119 | days: 7, 120 | }); 121 | 122 | const userDeleted = userLocked.delete("Competitor is better"); 123 | assertType>(true); 124 | assertObjectMatch( 125 | getState(userDeleted), 126 | { 127 | status: "deleted", 128 | deletionDate: new Date(), 129 | deletionReason: "Competitor is better", 130 | name: "John", 131 | email: "john@domain.org", 132 | age: 32, 133 | } satisfies State, 134 | ); 135 | 136 | const userNewDeletionReason = userDeleted.changeReason("Price is too high"); 137 | assertType>(true); 138 | assertObjectMatch( 139 | getState(userNewDeletionReason), 140 | { 141 | ...getState(userDeleted), 142 | deletionReason: "Price is too high", 143 | } satisfies State, 144 | ); 145 | }); 146 | 147 | await t.step("async transitions", async () => { 148 | const asyncUserMachine = createMachine< 149 | | User 150 | | (BaseUser & { 151 | status: "pending strict validation"; 152 | strictValidate: (email: string) => Promise; 153 | }) 154 | >({ 155 | transitions: { 156 | ...userMachine.transitions, 157 | strictValidate: async (prev, email) => { 158 | await new Promise((resolve) => setTimeout(resolve, 100)); 159 | return { ...prev, status: "validated", email }; 160 | }, 161 | }, 162 | methods: userMachine.methods, 163 | }); 164 | const userPending = asyncUserMachine.new({ 165 | name: "John", 166 | age: 32, 167 | status: "pending strict validation", 168 | }); 169 | 170 | const userValidated = await userPending.strictValidate("john@domain.org"); 171 | assertType>(true); 172 | assertEquals(getState(userValidated), { 173 | age: 32, 174 | email: "john@domain.org", 175 | name: "John", 176 | status: "validated", 177 | }); 178 | }); 179 | 180 | await t.step("methods", () => { 181 | const now = new Date(); 182 | const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); 183 | 184 | const userLocked = userMachine.new({ 185 | status: "locked", 186 | days: 10, 187 | lockStart: threeDaysAgo, 188 | name: "John", 189 | age: 32, 190 | email: "john@domain.org", 191 | }); 192 | 193 | assertEquals(userLocked.getRemainingDays(), 7); 194 | }); 195 | 196 | await t.step("onTransition", () => { 197 | const onUserUnlocked = spy(); 198 | const onUserDeleted = spy(); 199 | 200 | createMachine({ 201 | ...userMachine, 202 | onTransition: (prev, next) => { 203 | if (prev.status === "locked" && next.status === "validated") { 204 | onUserUnlocked(); 205 | } 206 | if (prev.status !== "deleted" && next.status === "deleted") { 207 | onUserDeleted(); 208 | } 209 | }, 210 | }).new({ 211 | status: "locked", 212 | days: 10, 213 | lockStart: new Date(), 214 | name: "John", 215 | age: 32, 216 | email: "", 217 | }).unlock().delete("Reason"); 218 | 219 | assertSpyCalls(onUserUnlocked, 1); 220 | assertSpyCalls(onUserDeleted, 1); 221 | }); 222 | 223 | await t.step("DeclareMachine type helper", () => { 224 | type User2 = DeclareMachine<{ 225 | base: { 226 | name: string; 227 | age: number; 228 | }; 229 | discriminant: "status"; 230 | states: { 231 | pending: { 232 | validate(email: string): User2["validated"]; 233 | }; 234 | validated: { 235 | email: string; 236 | 237 | changeEmail(): User2["pending"]; 238 | delete(reason: string): User2["deleted"]; 239 | lock(days: number): User2["locked"]; 240 | }; 241 | locked: { 242 | days: number; 243 | lockStart: Date; 244 | email: string; 245 | 246 | unlock(): User2["validated"]; 247 | delete(reason: string): User2["deleted"]; 248 | getRemainingDays(): number; 249 | }; 250 | deleted: { 251 | deletionReason: string; 252 | deletionDate: Date; 253 | email: string; 254 | 255 | changeReason(reason: string): User2["deleted"]; 256 | }; 257 | }; 258 | }>; 259 | 260 | type AreEquals = [T] extends [U] ? ([U] extends [T] ? true : false) 261 | : false; 262 | 263 | assertType>(true); 264 | assertType>(true); 265 | assertType>(true); 266 | assertType>(true); 267 | assertType>(true); 268 | 269 | const user2Machine = createMachine({ 270 | transitions: { 271 | validate: (prev, email) => ({ ...prev, status: "validated", email }), 272 | changeEmail: ({ ...prev }) => ({ ...prev, status: "pending" }), 273 | changeReason: (prev, reason) => ({ ...prev, deletionReason: reason }), 274 | delete: (prev, reason) => ({ 275 | ...prev, 276 | status: "deleted", 277 | deletionReason: reason, 278 | deletionDate: new Date(), 279 | }), 280 | lock: (prev, days) => ({ 281 | ...prev, 282 | status: "locked", 283 | days, 284 | lockStart: new Date(), 285 | }), 286 | unlock: (prev) => ({ ...prev, status: "validated" }), 287 | }, 288 | methods: { 289 | getRemainingDays: (state) => { 290 | const daysSinceLock = difference(state.lockStart, new Date()).days ?? 291 | 0; 292 | return Math.max(0, state.days - daysSinceLock); 293 | }, 294 | }, 295 | }); 296 | 297 | assertType>(true); 298 | }); 299 | }); 300 | --------------------------------------------------------------------------------