Reputation:
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.
Upvotes: 0
Views: 2187
Reputation: 1
You can use x-extensible-enum. For clarification you can get help with: https://opensource.zalando.com/restful-api-guidelines/#112
Upvotes: 0
Reputation: 329258
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, enum
s 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 enum
s 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!
Upvotes: 2