Hone logo
Hone
Problems

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:

  1. Generics for States and Events: The StateMachine class should be generic, accepting types for its states and events.
  2. 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).
  3. transition Method: A public method to attempt a transition by providing an event. This method should return a boolean indicating if the transition was successful.
  4. currentState Property: A public property to access the current state.
  5. 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.
  6. 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 currentState should be updated to the next state.
  • If no valid transition exists, the currentState should 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 StateMachine class 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 transition method 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.
Loading editor...
typescript