I am trying to create a state machine type where the transitions are enforced by the compiler.
I want to define the state machine like this:
type States = {
state1: {state: 'state1'},
state2: {state: 'state2'}
}
const Transitions: TransitionsType<States> = {
init() {
return {state: 'state1'}
},
state1: {
state2({...anyOtherItems}) {
// processing with anyOtherItems
// maybe return a load of extra stuff here
return {...anyOtherItems, state: 'state2'}
}
},
state2: {
state1({...others}) {
// same deal here
return {state: 'state1'}
}
}
};
transition(transitions, {state: 'state1'}, 'state2');
States is a type that describes the states of the machine, and a datatype for that state.
Transitions is an object that has the from state as the first level of the map, and the to is the second level of the map. The functions will be called for each transition, and they will take the type related to the from state, and return the type related to the to state. The init function is to kick it all off.
transition is a function that takes a state machine definition, a state object and a to state, and returns a new state object.
I want to get compile errors if the transition functions return objects with the wrong states, and if the transition function is called with a state object and a to state that are disallowed.
So far I have (and feel free to completely ignore this):
type StatesType<S> = {
[T in keyof S]: {state: T};
};
type TransitionTosType<S extends StatesType<S>, F extends keyof StatesType<S>> = {
[T in keyof S]?: (arg: S[F]) => S[T]
}
type TransitionsType<S extends StatesType<S>> = {
[F in keyof S]: TransitionTosType<S, F>
} & {
init: () => S[keyof S]
}
function transition<S extends StatesType<S>>(transitions: TransitionsType<S>,
state: S[keyof S],
to: keyof S & string): {state: string} {
const fromStateTransitions = transitions[state.state as keyof TransitionsType<S>];
if (fromStateTransitions) {
const toTransition = fromStateTransitions[to];
if (toTransition)
return toTransition(state);
}
throw new TypeError(`no transition from ${state.state} to ${String(to)}`);
}
This gives me an error on the line return toTransition(state);, and it does not give me an error if I try to transition to a disallowed state.
I'd be happy to accept any answer that can take the states and transitions given (States and Transitions in the example), and provides a function which takes a set of transitions (e.g. Transitions), a state context of the correct type (as given in a type like States), and string for the transition to, and where there will be compile errors if the state context is the wrong type, or the to state isn't a valid transition from the from state.
I am fine with the transition function accepting type parameters.
I will also accept an explanation of why it is impossible as an answer.
The main problem I see with your code example is that you have a conflict between manually specifying that
transitionsis of typeTransitionsType<States>, and allowing the compiler to infer something more specific from the initializing object literal. The typeTransitionsType<States>has a fairly explicit representation of theStatestype, which you need fortransition(transitions, ⋯)to function correctly. But if you use that type then the compiler has no idea about the specifics of the state graph and which nodes are connected to which. You really need to use a narrower type, such as shown here:where I've used the
satisfiesoperator to both verify the assignability to that type and to provide a context in which to infer the type oftransitions. That inferred type is:But then the type of
transitionsdoesn't explicitly contain theStatestype. So you'd needtransition(transitions, ⋯)to be able to tease apart the type oftransitionsto recover the originalStatestype, which isn't necessarily fully present (imagine a graph where one of the states is disconnected).There might be clever ways to get the best of both worlds here, but in my attempts the typing for
transition()became so complex that it doesn't seem worth it to me.Instead, my approach would be to accept that you need to explicitly pass two pieces of information to
transition(): both theStatestype at the type level, and the specific shape oftransitions. Ideally then the call would look likeBut unfortunately, that type-level type passing would require what's known as "partial type argument inference" as requested in microsoft/TypeScript#26272, which isn't currently supported in TypeScript. See Typescript: infer type of generic after optional first generic, for example. The workarounds involve some refactoring. The most common one is a type-level currying so that instead of
transition<States>(transitions, state, to), the calltransition<States>()returns a function which you call with(transitions, state, to)as arguments.It could look like this:
So now when you call
transition<States>(), the type ofSinside the function is set in stone, and can be used to constrainT(the generic type corresponding totransitions, andF(the generic type corresponding tostate), and consequently the type oftois an easily-written function ofFandT.That is, if
Tis the type oftransitions, andFis the type ofstate, thentomust be of typekeyof T[F["state"]](one of the keys of the property oftransitionscorresponding to the stateF). Note that I had to writeT[F["state"] & keyof T]to convince the compiler thatF["state"]is acceptable as a key ofT. It's not directly written that this is true, at least not for generics, and the compiler can't deduce it. An intersection frees up the compiler from worrying about it.Okay, let's try it out:
Looks good. The compiler only allows you to pass in arguments of the appropriate types, and will warn you if your
tostate is not directly accessible fromstate.Playground link to code