Search code examples
typescriptenumstype-safety

How do I require that an enum has certain members


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 ?


Solution

  • 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