I'm designing a lib API which involves some finite state-machines, so let's say that the lib exports the following interface:
export interface FSM<TStates> {
state: TStates
// ... other properties
}
The lib requires the state-machine to have the states 'started'
and 'finished'
. I've been trying without much success to encode this constraint into the type-system.
So far I've tried implementing this constraint as an enum:
export enum BaseState {
STARTED = 'started',
FINISHED = 'finished',
}
export interface FSM<TStates extends BaseState> {
state: TStates
// ... other properties
}
enum MyState {
STARTED = 'started',
OTHER = 'other',
FINISHED = 'finished',
}
// Type 'MyState' does not satisfy the constraint 'BaseState'.ts(2344)
let fsm: Fsm<MyState>
And I tried union-types
export type BaseState = 'started' | 'finished';
export interface FSM<TStates extends BaseState> {
state: TStates
// ... other properties
}
type MyState = 'started' | 'finished' | 'other'
// Type 'MyState' does not satisfy the constraint 'BaseState'.
// Type '"other"' is not assignable to type 'BaseState'.ts(2344)
let fsm: Fsm<MyState>
Is it possible to represent such constraint in typescript ?
UPD: by the way, if you can do it, it doesn't mean you should. Maybe the suggestion in comments to reframe the problem and use BaseState & TStates
for state
is a better one. Although you cannot define your own enum with these properties in such case.
Anyway
First with unions. As you've already understood, you cannot constrain the TStates
type argument to extend 'started' | 'finished'
, because then you won't be able to add extra fields. You can however require that 'started' | 'finished'
extend TStates
. One way is as such:
// Allow TStates to be any string
export type FSM<TStates extends string> =
'started' | 'finished' extends TStates ?
{
state: TStates
} :
never
If 'started' | 'finished'
doesn't extend TStates
, then you won't be able to assign any value to a variable of type FSM<TStates>
. You could also add a check that TStates
is not a string
itself, so that you won't be able to just use FSM<string>
export type FSM<TStates extends string> =
'started' | 'finished' extends TStates ?
string extends TStates ?
never :
{
state: TStates
} :
never
This will only accept string unions, because currently it's not possible to create a type like Exclude<string, 'foo'>
. If TStates extends string
, then it's either string
itself, or a union of string literals.
This looks a bit ugly, but maybe a utility type would help
type IfIncludesStartedFinished<TStates extends string, T> =
'started' | 'finished' extends TStates ?
string extends TStates ? never : T : never
export type FSM<TStates extends string> = IfIncludesStartedFinished<
TStates,
{
state: TStates
}
>
Although you should be careful with never
, because a variable of type never
is assignable to any other variable. Maybe using an opaque error type could help
declare const errorTypeSymbol: unique symbol
type ErrorType<TMessage extends string> = TMessage & {error: errorTypeSymbol}
type IfIncludesStartedFinished<TStates extends string, T> =
'started' | 'finished' extends TStates ?
string extends TStates ?
ErrorType<"TStates must be a union of strings, not a string"> :
T :
ErrorType<"TStates must include started and finished">
Now if something's gone wrong you will see an error message somewhere between typescript's complaints when you hover over the error. Although you should still be careful.
Now speaking of enums, it's a bit hacky. The idea behind string enums in typescript is that if you change the value of one, the code doesn't notice it and works the same way. If you require that the enum include some values, this is no longer the case, so maybe you should consider not doing it. But I don't think that this is an unforgivable sin, and you can totally not care about it if you want.
Recently in ts it became possible to get the type of enum values as a union of strings using template literal types, like `${Enum}`
(god it's not obvious how to include backticks in inline code when stackoverflow things they are part of markdown). You can use this to your advantage altering the type I described above just a bit
export type FSM<TStates extends string> = IfIncludesStartedFinished<
// In case TStates is a enum, turn it into a union of strings
`${TStates}`,
{
state: TStates
}
>
By the way, this will allow you to use both string unions and enums for TStates