Search code examples
typescriptenumstypescript-typings

Create extendable enums for use in extendable interfaces


Context: I am trying to develop a pattern for creating extendable state machines in typescript using the TypeState library. TypeState provides a typesafe state machine for Typescript, and while not central to the problem I am having it helps illustrate my goal.

Problem: I am running into issues creating a scalable pattern for extending enum in Typescript and implementing them in interface and class declarations.

Goal: The psuedocode below illustrates what I would like my pattern to look like.

1) Define base enum States

2) Extend enum States with additional states resulting in enum ExtendedStates

2) Define ParentInterface using States and typed state machine

3) Extend ParentInterface via ChildInterface and override States with ExtendedStates

4) Implement ParentInterface in class Parent

5) Extend class Parent in class Child implementing ChildInterface

6) Be able to call broadcastState() from either class and get the current state.

I have used this pattern to great effect in other languages, and I would appreciate some help understanding the limitations of Typescript and any alternative patterns that can achieve the same goal.

import {TypeState} from "typestate";

enum States {
  InitialState
}

// extends is not available on enum, looking for alternative
enum ExtendedStates extends States {
  AdditionalState
}

/////////////////////////////////////////
// this works fine
interface ParentInterface {
  fsm: TypeState.FiniteStateMachine<States>;
  states: typeof States;
  message: string;
}

// incorrectly extends ParentInterface, types of fsm/states are incompatible
interface ChildInterface extends ParentInterface {
  fsm: TypeState.FiniteStateMachine<ExtendedStates>;
  states: typeof ExtendedStates;
}

/////////////////////////////////////////

class Parent implements ParentInterface {
  public fsm: TypeState.FiniteStateMachine<States>;
  public states: typeof States;
  public message: string = "The current state is: ";

  constructor(state: States | undefined) {
    state = state ? state : this.states.InitialState;
    this.fsm = new TypeState.FiniteStateMachine(state);
    this.broadcastCurrentState();
  }

  public broadcastCurrentState(): void {
    console.log(this.message + this.fsm.currentState);
  }
}

class Child extends Parent implements ChildInterface {
  public fsm: TypeState.FiniteStateMachine<ExtendedStates>;
  public states: typeof ExtendedStates;

  constructor(state: ExtendedStates | undefined) {
    state = state ? state : this.states.InitialState;
    this.fsm = new TypeState.FiniteStateMachine(ExtendedStates);
    this.broadcastCurrentState();
  }
}

Closest I've Gotten

import {TypeState} from "typestate";

enum States {
  InitialState
}

enum ExtendedStates {
  InitialState,
  ExtendedState
}

class Parent {
  public fsm: TypeState.FiniteStateMachine<States>;
  public states: typeof States;
  public message: string = "The current state is: ";

  // T is declared but never used
  constructor(state: <T> | undefined) {
    state = state ? state : this.states.InitialState;
    // cannot find name T
    this.fsm = new TypeState.FiniteStateMachine<T>(state);
    this.broadcastCurrentState();
  }

  public broadcastCurrentState(): void {
    console.log(this.message + this.fsm.currentState);
  }
}

// types of fsm are incompatible
class Child extends Parent {
  public fsm: TypeState.FiniteStateMachine<ExtendedStates>;
  public states: typeof ExtendedStates;

  constructor(state: ExtendedStates | undefined) {
    // Param not assignable to type <T>
    super(state);
  }
}

This attempt gets close to desired results, but does not compile and results in a lot of code duplication in the enum. It also loses the interfaces, which are not a requirement but do provide a nice safety net.

I'd love to hear what you all have to say. I feel like this is a powerful pattern and I am missing something simple in order to achieve it.


Solution

  • One reason that it doesn't compile is because Child isn't a proper subtype of Parent. The Liskov substitution principle says you should be able to use a Child object as a Parent object. If I ask a Parent object for which state its state machine is in, and it tells me ExtendedState, then I've got a broken Parent, right? So a Child is a broken Parent, which is bad, and is what TypeScript is warning you about.

    Probably it's better to forget having a superclass/subclass relationship and just have a generic class:

    class Generic<T extends States> {
      public fsm: TypeState.FiniteStateMachine<T>;
      public states: T;
      public message: string = "The current state is: ";
    
      // T[keyof T] means the values of T, in this case InitialState, etc    
      constructor(state: T[keyof T] | undefined) {
        state = state ? state : this.states.InitialState;
        // cannot find name T
        this.fsm = new TypeState.FiniteStateMachine<T>(state);
        this.broadcastCurrentState();
      }
    
      public broadcastCurrentState(): void {
        console.log(this.message + this.fsm.currentState);
      }
    }
    

    Now that would work if States were the right kind of object, but as you can noted, enums aren't really that full-featured enough to be used in this way: you can't get anything to extend them. So instead of using an enum, why not use an object which emulates it:

    // make our own enum
    type Enum<T extends string> = {[K in T]: K};
    
    // create an enum from given values
    function makeEnum<T extends string>(...vals: T[]): Enum<T> {
      const ret = {} as Enum<T>;
      vals.forEach(k => ret[k] = k)
      return ret;
    }
    
    // take an existing enum and extend it with more values
    function extendEnum<T extends string, U extends string>(
      firstEnum: Enum<T>, ...vals: U[]): Enum<T | U> {
        return Object.assign(makeEnum(...vals), firstEnum) as any;  
    }
    

    In this case, an Enum<> is an object with specified string keys, whose values are the same as the key (this is a bit different from regular enums whose values are numbers. If you really want numbers that can probably be arranged but it would be more annoying to implement. I've never used the TypeState library, so I don't know if it cares if the values are numbers or strings.) Now you can create your States and ExtendedStates like this:

    const States = makeEnum('InitialState'); 
    type States = typeof States; 
    // States is { InitialState: 'InitialState' };
    
    const ExtendedStates = extendEnum(States, 'ExtendedState');
    type ExtendedStates = typeof ExtendedStates;
    // ExtendedStates is { InitialState: 'InitialState', ExtendedState: 'ExtendedState' };
    

    and create objects like this:

    const parentThing = new Generic<States>(States.InitialState);
    const childThing = new Generic<ExtendedStates>(ExtendedStates.InitialState);
    

    Hope that helps; good luck!