Type-Safe State Machine with TypeScript
This challenge focuses on building a robust and type-safe state machine in TypeScript. State machines are fundamental to managing complex application logic, ensuring predictable behavior and preventing invalid transitions. By leveraging TypeScript's type system, we can achieve compile-time guarantees against incorrect state usage, leading to more reliable and maintainable code.
Problem Description
Your task is to implement a generic StateMachine class in TypeScript that enforces type safety for states and events. The state machine should:
- Define States: Allow users to define a set of possible states for the machine.
- Define Events: Allow users to define a set of possible events that can trigger transitions.
- Define Transitions: Specify which events can transition the machine from one state to another.
- Maintain Current State: Keep track of the current state of the machine.
- Handle Transitions: Provide a method to dispatch events, which should trigger valid transitions and update the current state.
- Prevent Invalid Transitions: Ensure that dispatching an event that is not allowed in the current state does not result in a state change and potentially logs an error or throws an exception.
- Optional State Data: Support associating arbitrary data with each state.
Key Requirements:
- Generics for States and Events: The
StateMachineclass should be generic, accepting types for its states and events. - Transition Mapping: A clear mechanism to define valid transitions (e.g., a map where keys are current states and values are maps of allowed events to next states).
transitionMethod: A public method to attempt a transition by providing an event. This method should return a boolean indicating if the transition was successful.currentStateProperty: A public property to access the current state.- Type Safety: All operations, especially transitions, must be type-checked by TypeScript. For example, dispatching an event that doesn't exist or attempting a transition from an invalid state should be caught at compile time if possible, or at runtime with clear error handling.
- Optional State Data: States should be able to carry associated data. This data should also be type-safe.
Expected Behavior:
When an event is dispatched:
- If a valid transition exists for the current state and the dispatched event, the
currentStateshould be updated to the next state. - If no valid transition exists, the
currentStateshould remain unchanged. - An optional callback can be executed upon successful transition.
Edge Cases:
- Initial state configuration.
- Dispatching events when no transitions are defined for the current state.
- Handling states with and without associated data.
Examples
Let's consider a simple traffic light state machine.
Example 1: Basic Traffic Light
Input:
type TrafficLightState = 'red' | 'yellow' | 'green';
type TrafficLightEvent = 'TIMER_TICK';
const transitions = {
red: {
TIMER_TICK: 'green'
},
green: {
TIMER_TICK: 'yellow'
},
yellow: {
TIMER_TICK: 'red'
}
};
const trafficLight = new StateMachine<TrafficLightState, TrafficLightEvent>(
'red', // initial state
transitions
);
console.log(trafficLight.currentState); // 'red'
trafficLight.transition('TIMER_TICK');
console.log(trafficLight.currentState); // 'green'
trafficLight.transition('TIMER_TICK');
console.log(trafficLight.currentState); // 'yellow'
trafficLight.transition('TIMER_TICK');
console.log(trafficLight.currentState); // 'red'
Output:
red
green
yellow
red
Explanation: The traffic light starts in 'red'. A 'TIMER_TICK' event moves it to 'green', then to 'yellow', and finally back to 'red', cycling through the defined states.
Example 2: Invalid Transition Attempt
Input:
type TrafficLightState = 'red' | 'yellow' | 'green';
type TrafficLightEvent = 'TIMER_TICK' | 'EMERGENCY_STOP';
const transitions = {
red: {
TIMER_TICK: 'green'
},
green: {
TIMER_TICK: 'yellow'
},
yellow: {
TIMER_TICK: 'red'
}
// No transitions defined for EMERGENCY_STOP from any state
};
const trafficLight = new StateMachine<TrafficLightState, TrafficLightEvent>(
'red',
transitions
);
console.log(trafficLight.currentState); // 'red'
const transitioned = trafficLight.transition('EMERGENCY_STOP');
console.log(`Transitioned: ${transitioned}`); // Transitioned: false
console.log(trafficLight.currentState); // 'red' (state did not change)
Output:
red
Transitioned: false
red
Explanation: Attempting to dispatch 'EMERGENCY_STOP' when the light is 'red' results in false being returned, and the state remains 'red' because no transition is defined for this event in the 'red' state.
Example 3: State with Data
Input:
interface UserStateData {
userId: string;
permissions: string[];
}
type UserState = 'loading' | 'authenticated' | 'unauthenticated';
type UserEvent = 'FETCH_SUCCESS' | 'FETCH_FAILURE' | 'LOGOUT';
const userTransitions = {
loading: {
FETCH_SUCCESS: 'authenticated',
FETCH_FAILURE: 'unauthenticated'
},
authenticated: {
LOGOUT: 'unauthenticated'
},
unauthenticated: {
// Could add a LOGIN event here if desired
}
};
const userData: UserStateData = { userId: 'user-123', permissions: ['read'] };
const userStateMachine = new StateMachine<UserState, UserEvent, UserStateData>(
'loading',
userTransitions,
(event, fromState, toState, data) => {
console.log(`Transitioned from ${fromState} to ${toState} on ${event}`);
if (toState === 'authenticated' && data) {
console.log(`User ID: ${data.userId}, Permissions: ${data.permissions.join(', ')}`);
}
}
);
console.log(userStateMachine.currentState); // 'loading'
userStateMachine.transition('FETCH_SUCCESS', userData);
// Expected output from callback:
// Transitioned from loading to authenticated on FETCH_SUCCESS
// User ID: user-123, Permissions: read
console.log(userStateMachine.currentState); // 'authenticated'
userStateMachine.transition('LOGOUT');
// Expected output from callback:
// Transitioned from authenticated to unauthenticated on LOGOUT
console.log(userStateMachine.currentState); // 'unauthenticated'
Output:
loading
Transitioned from loading to authenticated on FETCH_SUCCESS
User ID: user-123, Permissions: read
authenticated
Transitioned from authenticated to unauthenticated on LOGOUT
unauthenticated
Explanation: This example shows a user authentication state machine. The 'authenticated' state can carry UserStateData. A callback is provided to log transition details and user data when authenticated.
Constraints
- The
StateMachineclass must be implemented using TypeScript. - The implementation should strive for immutability where practical, though the current state will necessarily be mutable.
- No external libraries are permitted for the core state machine logic.
- The provided transition definition structure should be adhered to.
- The callback function for transitions should accept arguments for the event, previous state, new state, and any associated data.
Notes
- Consider how you will represent the transitions. A nested object or a Map could be suitable.
- Think about how to handle the initial state and ensure it's a valid state.
- How will you ensure type safety when accessing state data?
- The
transitionmethod should ideally return a boolean indicating success or failure. - Consider adding a callback mechanism to execute logic upon successful state transitions. This callback should also be type-aware.
- The problem statement implies a finite state machine (FSM). Ensure your implementation aligns with FSM principles.
- For compile-time safety with state data, consider how you might map states to their specific data types. A discriminated union or a mapped type could be useful here.