Search code examples
typescriptgraphqlcode-generation

Is it possible to generate the values from a Typescript union of literal types in runtime?


I am using GraphQL code generator to generate TypesScript types from a graphql sdl schema definition. The relevant part of the schema defines an union from four types, and looks something like this:

union Game = GameLobby | GamePlaying | GameOverWin | GameOverTie

type GameLobby {
  id: ID!
}

type GamePlaying {
  id: ID!
  player1:String!
  player2:String!
}

type GameOverWin {
  id: ID!
  winner:String!
}

type GameOverTie {
  id: ID!
}

And generates the following TypeScript type definitions:

export type Game = GameLobby | GamePlaying | GameOverWin | GameOverTie;

export type GameLobby = {
  __typename?: "GameLobby";
  readonly id: Scalars["ID"];
};

export type GameOverTie = {
  __typename?: "GameOverTie";
  readonly id: Scalars["ID"];
};

export type GameOverWin = {
  __typename?: "GameOverWin";
  readonly id: Scalars["ID"];
  readonly winner: String;
};

export type GamePlaying = {
  __typename?: "GamePlaying";
  readonly player1: String;
  readonly player2: String;
};

Now, I want to be able to use a type union in runtime to allow me to discriminate in which state the game currently is. I can define such union like this :

// assume this gives back the generated types:
import { Game } from "./generated/models";

// we only want the actual discriminants
type GameStatus = Exclude<Game["__typename"], undefined>;

With this type I'm able to strictly type any value that might need the GameStatus, for example:

class GameModel {
  public readonly id!: number;
  public readonly status!: GameStatus;
}

Finally, I want to be able to map the game status to a persisted state, and for that I need to enumerate all the possible values that GameStatus can actually take. In order to do so, ideally I would like to not have to re-type the values, but also if I have to I'd like at least to be sure I didn't miss any of them.

Right now, this is how I'm ensuring I'm covering all the possible values GameStatus can take:

function assertNever(value: never): never {
  throw new Error(`unexpected value ${value}`);
}

export const GameModelLobby: GameModelState = "GameLobby";
export const GameModelPlaying: GameModelState = "GamePlaying";
export const GameModelOverWin: GameModelState = "GameOverWin";
export const GameModelOverTie: GameModelState = "GameOverTie";

const gameStatus = [
  GameModelLobby, 
  GameModelPlaying, 
  GameModelOverWin, 
  GameModelOverTie
];

// ensure we didn't forget any state
gameStatus.forEach(status => {
  switch (status) {
    case GameModelLobby:
      break;
    case GameModelPlaying:
      break;
    case GameModelOverWin:
      break;
    case GameModelOverTie:
      break;
    default:
      assertNever(status);
  }
});

This makes tsc check all values are covered, or removed, as the underlying GraphQL schema changes. Sort of a runtime/static check hybrid, because I'm leaving the code to execute in runtime, but tsc will also check statically...

And the question is: Is it possible somehow to generate the values from an union of literal types in runtime? Alternatively: Is it possible to generate a TypeScript Enum from an union of literal types in runtime?

If none of these two are possible: Is there a more succinct way to typecheck and ensure no cases are left behind?

Update

Following the answer from @dezfowler and with some minor changes, this is how I solved the issue:

First extract the discriminator types from the GameState union type:

import { GameState } from "./generated/models";
export type GameStateKind = Exclude<GameState["__typename"], undefined>;

Then build a mapped type (which is kind-of a tautology) and maps the types to values in a type-safe way. The map forces you to use all the types as keys and write all the values, so unless every discriminant is there it is not going to complile:

export const StateKindMap: { [k in GameStateKind]: k } = {
  GameLobby: "GameLobby",
  GameOverTie: "GameOverTie",
  GameOverWin: "GameOverWin",
  GamePlaying: "GamePlaying"
};

Export all the types as an array, which I can then use to create enums in the database model:

export const AllStateKinds = Object.values(StateKindMap);

And finally, I wrote a little test to make sure I can directly use StateKindMap to discriminate over GameStateKind (this test is redundant because all the required checks are done by tsc):

import { StateKindMap, AllStateKinds } from "./model";

describe("StateKindMap", () => {
  it("should enumerate all state kinds", () => {
    AllStateKinds.forEach(kind => {
      switch (kind) {
        case StateKindMap.GameLobby:
          break;
        case StateKindMap.GameOverTie:
          break;
        case StateKindMap.GameOverWin:
          break;
        case StateKindMap.GamePlaying:
          break;
        default:
          assertNever(kind);
      }
    });
  });
});

function assertNever(value: never): never {
  throw new Error(`unexpected value ${value}`);
}

Solution

  • I realised I forgot to answer the first part of your question about generating stuff at runtime. This isn't possible as there is no representation of the TypeScript type system in the JavaScript code. A workaround for this is to create a dummy object (using the mapped type technique below) which forces you to add keys for all the union values in order for it to compile. You then just do Object.keys() passing that dummy object in order to get the array of game status string values.

    As for the second part of the question about more succinct type checking...

    You can use a mapped type for this...

    type StrategyMap<T> = { [K in GameStatus]: T };
    

    Which you can then use like this...

    const title: StrategyMap<string> = {
        GameLobby: "You're in the lobby",
        GameOverTie: "It was a tie",
        GameOverWin: "Congratulations",
        GamePlaying: "Game on!"
    };
    

    or this...

    const didYouWin = false;
    const nextStatusDecision: StrategyMap<() => GameStatus> = {
        GameLobby: () => "GamePlaying",
        GameOverTie: () => "GameLobby",
        GameOverWin: () => "GameLobby",
        GamePlaying: () => didYouWin ? "GameOverWin" : "GameOverTie"
    };
    
    const currentStatus: GameStatus = "GamePlaying";
    const nextStatus = nextStatusDecision[currentStatus]();
    

    If you forget a status you'll get a warning, e.g.

    Property 'GamePlaying' is missing in type '{ GameLobby: string; GameOverTie: string; GameOverWin: string; }' but required in type 'StrategyMap<string>'.

    TypeScript playground example here.