- Notifications
You must be signed in to change notification settings - Fork0
Lightweight state machines in TypeScript
License
WinstonFassett/matchina
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Matchina is a TypeScript-first, lightweight toolkit for building type-safe state machines, with powerful pattern matching and async handling. It provides:
- Strongly-typed state machines with full type inference
- Tagged unions via the
matchboxpattern for discriminated types - Type-safe transitions with smart parameter inference
- Promise integration for handling async operations safely
- Optional integrations with libraries like Immer, Valibot, Zod, and React
- TypeScript-first design with powerful type inference
- Nano-sized, opt-in primitives for state machines and async logic
- Composable APIs that work together or standalone
- Inspired bysuchipi/safety-match,christianalfoni/timsy,pelotom/unionize, andXState
- Nano-sized, opt-in primitives for state machines and async logic.
- Inspired byTimsy,XState, and Andre Sitnik’s nano library style.
- Each primitive is useful standalone or composable.
A super lightweight, strongly-typed toolkit for building and extending state machines, factories, and async flows in TypeScript. Use only what you need.
Type-Safe State Factories:
- Create discriminated union types with
matchboxFactory()for powerful pattern matching - Build factory machines with complete type inference for states and transitions
- Enjoy automatic type narrowing with
.is()and.as()type guards
- Create discriminated union types with
Smart Transitions:
- Define transitions with TypeScript inferring parameter types from destination states
- Trigger transitions with fully typed parameters based on target state requirements
- Handle complex transition logic with guards and side effects
Async Handling:
createPromiseMachinefor type-safe async state management- Lifecycle hooks for promises with appropriate typing
- Safe error handling for rejected promises
React Integration:
- React hooks for consuming state machines
- Type-safe component rendering based on state
npm install matchina
- Seethe docs site for live examples, guides, and API reference.
- All examples in the docs are real, runnable code from the repo's
examples/directory.
import{matchina,defineStates}from"matchina";// Define states with their associated data typesconststates=defineStates({Idle:undefined,Playing:(trackId:string)=>({ trackId}),Paused:(trackId:string)=>({ trackId}),Stopped:undefined,});constplayer=matchina(states,// Define transitions - TypeScript infers parameter types based on destination states{Idle:{start:"Playing"// Parameter types are automatically matched},Playing:{pause:"Paused",// State transition will preserve trackIdstop:"Stopped"},Paused:{resume:"Playing",// State transition will preserve trackIdstop:"Stopped"},Stopped:{start:"Playing"// Parameter types are automatically matched},},"Idle");// Usage with full type safety:player.start("song-123");// TypeScript knows this needs a trackIdconsole.log(player.getState().key);// "Playing"// TypeScript knows player.getState().data has trackId when in Playing stateif(player.getState().is("Playing")){console.log(player.getState().data.trackId);// "song-123"}player.pause();// TypeScript knows no parameter neededplayer.resume();// TypeScript knows no parameter needed// Pattern matching with exhaustive checkingconstmessage=player.getState().match({Playing:({ trackId})=>`Now playing:${trackId}`,Paused:({ trackId})=>`Paused:${trackId}`,Idle:()=>"Ready to play",Stopped:()=>"Playback stopped",});
Type-safe state machines for managing asynchronous operations:
import{createPromiseMachine,setup,effect,guard,enter,leave,}from"matchina";// Create a promise machine for async addition with full type inferenceconstadder=createPromiseMachine((a:number,b:number)=>newPromise<number>((resolve)=>setTimeout(()=>resolve(a+b),500)),);// All hooks are strongly typed and TypeScript checkedsetup(adder)(// Type-safe guard to validate parametersguard((ev)=>{// TypeScript knows ev.params exists and contains our parametersif(ev.type!=="executing")returntrue;const[a,b]=ev.params[1];returna>=0&&b>=0;// Only allow non-negative numbers}),// Type-safe hooks for state transitionsenter((ev)=>{if(ev.to.is("Pending")){// TypeScript knows ev.to.data has promise and params when in Pending stateconsole.log("Started addition:",ev.to.data.params);}}),// Log when leaving pending stateleave((ev)=>{if(ev.from.is("Pending")){console.log("Leaving pending state");}}),// Log when promise resolveseffect((ev)=>{if(ev.type==="resolveExit"){// TypeScript knows ev.to.data contains the result when in Resolved stateconsole.log("Promise resolved with:",ev.to.data);}}),);// --- Usage with type safety ---// Execute with properly typed parametersconstdone=adder.execute(2,3);awaitdone;// TypeScript knows this is Promise<number>// Pattern match on state for messagingconstmessage=adder.getState().match({Idle:()=>"Ready to add.",Pending:(params)=>`Adding:${params.params.join(" + ")}`,Resolved:(result)=>`Result:${result}`,Rejected:(error)=>`Error:${error.message}`,});
The foundation of Matchina is thematchbox pattern for creating type-safe tagged unions:
import{matchbox}from"matchina";// Create a state factory with typed data for each stateconstPlayerState=matchbox({Idle:()=>({}),Playing:(trackId:string,startTime:number=Date.now())=>({ trackId, startTime,}),Paused:(trackId:string,position:number)=>({ trackId, position,}),Stopped:()=>({}),});// Type-safe state creation:constplayingState=PlayerState.Playing("track-123");constpausedState=PlayerState.Paused("track-123",45);// Exhaustive pattern matching with access to state data:constmessage=playingState.match({Playing:({ trackId, startTime})=>`Now playing:${trackId} (started at${newDate(startTime).toLocaleTimeString()})`,Paused:({ trackId, position})=>`Paused:${trackId} at${position} seconds`,Idle:()=>"Ready to play",Stopped:()=>"Playback stopped",});// Type-safe guards with type narrowing:if(playingState.is("Playing")){// TypeScript knows playingState.data has trackId and startTimeconsole.log(`Playing${playingState.data.trackId} since${playingState.data.startTime}`,);}// Type-safe casting:try{constplaying=pausedState.as("Playing");// Will throw at runtimeconsole.log(playing.data.startTime);// Wouldn't reach this line}catch(e){console.error("Can't cast Paused state to Playing");}
ThematchboxFactory() function provides the foundation for Matchina's state machines, offering:
- Type-safe state creation with proper parameter inference
- Exhaustive pattern matching for handling all possible states
- Type guards that narrow types for precise type checking
- Type-safe casting for advanced use cases
Factory Machines combine state factories with transitions for complete type safety:
import{defineStates,createMachine,setup,guard,enter}from"matchina";// Define your state factory with proper typesconstTaskStates=defineStates({Idle:()=>({}),Loading:(query:string)=>({ query}),Success:(query:string,results:string[])=>({ query, results}),Error:(query:string,message:string)=>({ query, message}),});// Create a factory machine with type-safe transitionsconsttaskMachine=createMachine(TaskStates,{Idle:{// Simple string transition - parameter types are inferredsearch:"Loading"},Loading:{// Need functions here because we're adding new datasuccess:(results:string[])=>TaskStates.Success(results),error:(message:string)=>TaskStates.Error(message)},Success:{// Return to idle stateclear:"Idle",// Start a new searchsearch:"Loading"},Error:{// Return to idle stateclear:"Idle",// Retry the same queryretry:({ from})=>TaskStates.Loading(from.data.query)},},"Idle",);// Add hooks with proper typingsetup(taskMachine)(// Guard to prevent empty searchesguard((ev)=>{if(ev.type!=="search")returntrue;returnev.params[0].length>0;}),// Log state transitionsenter((ev)=>{console.log(`Entering${ev.to.key} state`,ev.to.data);}),);// Usage with full type safetytaskMachine.send("search","typescript");// When in Loading state, we can send success with resultsif(taskMachine.getState().is("Loading")){// Simulate API responsesetTimeout(()=>{taskMachine.send("success",["result1","result2"]);},1000);}// Pattern match on current state for UI renderingconstui=taskMachine.getState().match({Idle:()=>"Enter a search term",Loading:({ query})=>`Searching for "${query}"...`,Success:({ query, results})=>`Found${results.length} results for "${query}":${results.join(", ")}`,Error:({ query, message})=>`Error searching for "${query}":${message}`,});
The Factory Machine provides:
- Type-safe state transitions with parameter inference
- State data preservation when transitioning between states
- Lifecycle hooks for intercepting and reacting to state changes
- Pattern matching for exhaustive state handling
Matchina provides powerful hooks for intercepting and reacting to state transitions:
import{matchina,setup,guard,enter,leave,effect,bindEffects,}from"matchina";// Create a counter machine with effectsconstcounter=matchina(defineStates({Idle:()=>({count:0}),Counting:(count:number)=>({ count}),// Define an effect state that has a specific effect typeMilestone:(count:number)=>({ count,effect:"Notify"asconst}),}),{Idle:{start:"Counting",},Counting:{increment:({ from})=>({key:"Counting",data:{count:from.data.count+1},}),reset:"Idle",},Milestone:{acknowledge:"Counting",},},"Idle",);// Add lifecycle hooks with type safetysetup(counter)(// Guard - prevent incrementing past 100guard((ev)=>{if(ev.type!=="increment")returntrue;if(!ev.from.is("Counting"))returntrue;returnev.from.data.count<100;}),// Enter hook - log entering Milestone stateenter((ev)=>{if(ev.to.is("Milestone")){console.log(`Milestone reached:${ev.to.data.count}`);}}),// Leave hook - log leaving statesleave((ev)=>{console.log(`Leaving${ev.from.key} state`);}),// Effect handler - handle the Notify effectbindEffects({Notify:({ data})=>{// Show notificationalert(`Milestone reached:${data.count}`);},}),);// Usagecounter.start();counter.increment();// count: 1counter.increment();// count: 2// When count reaches 10, Milestone effect will trigger
Lifecycle hooks provide:
- Guards to prevent invalid transitions
- Enter/Leave hooks to react to state changes
- Effect handlers for handling side effects
- Type-safe API that enforces correct usage
Matchina provides integration with React components:
importReactfrom"react";import{matchina}from"matchina";import{onLifecycle}from"matchina";// Create a todo machineconststates=defineStates({Empty:()=>({}),Active:(todos:string[])=>({ todos}),Saving:(todos:string[])=>({ todos}),Error:(message:string)=>({ message}),});consttodoMachine=matchina(states,{Empty:{// Need a function here because we're creating a new arrayadd:(todo:string)=>states.Active([todo])},Active:{// Need a function here to modify existing dataadd:(todo:string)=>({ from})=>states.Active([...from.data.todos,todo]),// Need a function here to modify existing dataremove:(index:number)=>({ from})=>states.Active(from.data.todos.filter((_,i)=>i!==index)),save:"Saving"// Parameters automatically preserved},Saving:{success:"Active",// Parameters automatically preservederror:(message:string)=>states.Error(message)},Error:{dismiss:({ from})=>from?.is("Saving") ?"Active" :"Empty"},},"Empty");// Todo component using the machineconstTodoApp:React.FC=()=>{const[newTodo,setNewTodo]=React.useState("");constmachine=todoMachine;// Register lifecycle hook for the Saving stateonLifecycle(machine,{Saving:{enter:({ to})=>{// Simulate API callsetTimeout(()=>{if(Math.random()>0.8){machine.error("Failed to save todos");}else{machine.success();}},1000);}}});return(<div><h1>Todo App</h1><formonSubmit={(e)=>{e.preventDefault();if(newTodo.trim()){machine.add(newTodo);setNewTodo("");}}}><inputvalue={newTodo}onChange={(e)=>setNewTodo(e.target.value)}placeholder="New todo"/><buttontype="submit">Add</button></form>{machine.getState().match({Empty:()=><p>No todos yet. Add one to get started!</p>,Active:({ todos})=>(<><ul>{todos.map((todo,index)=>(<likey={index}>{todo}<buttononClick={()=>machine.remove(index)}>Remove</button></li>))}</ul><buttononClick={machine.save}>Save</button></>),Saving:()=><p>Saving todos...</p>,Error:({ message})=>(<divclassName="error"><p>Error:{message}</p><buttononClick={machine.dismiss}>Dismiss</button></div>),})}</div>);};
The React integration provides:
- Type-safe event handlers that match your machine's API
- Pattern matching for rendering based on machine state
- Lifecycle hooks for reacting to state changes
npm install matchina
For detailed documentation, examples, and API reference, visit:
- Getting Started
- Matchbox Tutorial
- Factory Machines
- Promise Handling
- Lifecycle Hooks
- React Integration
- Full Examples
Matchina is designed to be modular and agnostic of any specific libraries. It can be integrated with various tools as needed.
Matchina's store machine provides a simple way to manage state with transitions:
import{createStoreMachine}from'matchina';// Example with proper type checkingconststore=createStoreMachine(0,{increment:(amt:number=1)=>(change)=>change.from+amt,decrement:(amt:number=1)=>(change)=>change.from-amt,set:(next:number)=>next,reset:()=>0,});// These calls are now properly type-checked:store.dispatch("increment");// Works with default parameterstore.dispatch("increment",5);// Works with explicit parameterstore.dispatch("decrement");// Works with default parameterstore.dispatch("set",42);// Requires a number parameterstore.dispatch("reset");// No parameters requiredconsole.log(store.getState());// 0
You can use Immer with matchina's store machine for immutable state updates. Here are two patterns:
import{produce}from'immer';import{createStoreMachine}from'matchina';// Pattern 1: Direct transitions with ImmerconstwithImmer=<T>(fn:(draft:T)=>void)=>{return(state:T):T=>produce(state,fn);};// UsageconstinitialState={user:{name:"Alice",preferences:{notifications:true}}};conststore=createStoreMachine(initialState,{updateUserName:(name:string)=>withImmer((draft)=>{draft.user.name=name;})});// Pattern 2: Curried transitions with Immerconststore2=createStoreMachine(initialState,{// For transitions that need access to the change objecttoggleNotifications:()=>(change)=>{returnproduce(change.from,(draft)=>{draft.user.preferences.notifications=!draft.user.preferences.notifications;});}});
You can integrate validation libraries like Valibot or Zod with matchina:
Matchina provides several ways to hook into state transitions:
import{matchina,defineStates,setup,transitionHooks,onLifecycle,guard,enter,leave}from'matchina';conststates=defineStates(stateDefinitions);constmachine=matchina(states,transitions,states.InitialState());// Method 1: Using setup with individual hookssetup(machine)(guard((ev)=>{// Prevent invalid transitionsreturntrue;}),enter((ev)=>{console.log(`Entering${ev.to.key} state`);}),leave((ev)=>{console.log(`Leaving${ev.from.key} state`);}));// Method 2: Using transitionHookssetup(machine)(transitionHooks({guard:(ev)=>true},{enter:(ev)=>console.log(`Entering${ev.to.key} state`)},{leave:(ev)=>console.log(`Leaving${ev.from.key} state`)}));// Method 3: Using onLifecycleonLifecycle(machine,{'*':{// For any stateenter:(ev)=>console.log(`Entering${ev.to.key} state`),leave:(ev)=>console.log(`Leaving${ev.from.key} state`),on:{'*':{// For any eventguard:(ev)=>true}}},SpecificState:{enter:(ev)=>console.log('Entering specific state'),on:{specificEvent:(ev)=>console.log('Handling specific event')}}});// For promise machines, you can use guardExecute to prevent starting the promisesetup(promiseMachine)(guardExecute((ev)=>{if(ev.type==='execute'){// Validate parameters before executingconst[value]=ev.params;returnvalue>0;// Only allow positive values}returntrue;// Allow other transitions}));
Matchina focuses on TypeScript type inference and composable, lightweight primitives. Unlike XState, it doesn't use a declarative JSON-based configuration. Instead, it provides functional APIs with strong typing. Compared to Timsy, it offers more flexible state transitions and enhanced pattern matching via Matchbox.
No. Matchina is designed to be modular. You can use only the parts you need:
- Use
matchboxFactoryalone for type-safe tagged unions - Use
createMachinefor state machines - Use
createPromiseMachinefor async operations - Use React integrations if you're working with React
Matchina is very lightweight:
| Feature | Size (min+gz) |
|---|---|
| matchbox | 381 B |
| factory-machine | 618 B |
| promise-machine | 1.18 kB |
| react integration | 397 B |
| zod integration | 437 B |
| valibot integration | 680 B |
| full library | 3.42 kB |
Contributions are welcome! Feel free to:
- Report issues and bugs
- Suggest new features or improvements
- Submit pull requests
MIT
About
Lightweight state machines in TypeScript
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.