RSS feed iconGitHub logo

Representing wizard state in TypeScript

2022-09-0910 minutes readTypeScript

While converting the Externals workflow at Splitgraph from a standalone set of pages into the Splitgraph Console, the natural action to do was to make the set of pages into a multi-step wizard. During that process I had to somehow represent the state of the wizard given some constraints and assumptions.

Wizard behavior assumptions

Here are general assumptions about the UI and behavior of the wizard. Most of them are natural, but it is worth writing them down so we know what requirements we are working with.

  1. The wizard has multiple steps 1, 2, ..., N (where N > 1).
  2. The wizard displays only a single step at a time.
  3. The user can go forward from step i to step i+1 (where i < N).
  4. The user can go back from step i to step i-1 (where i > 1).
  5. Each step relies on some information provided to it from previous steps (or from an external source, like the URL, if it is the first step).

Steps state

Given all of the requirements above, I came up with the following idea of steps state representation.

Given a wizard with N steps where each step has its own state, if a user is on a step i, this means:

  1. The state for steps 1, 2, ..., i must be available.

    The user must have visited these steps prior to moving to step i, because the user is allows to go forward only one step at a time.

  2. The state for steps i+1, i+2, ..., N can be, must does not have to be available.

    The user could have gone to a further step, and then came back to step i. This is why the state of the following steps is optional in the wizard state.

    The state of a step is not erased when going back so that a previous step can optionally use it. In the Externals wizard case, if the user picked an external plugin on step 1, filled in the connection information on step 2, went back to step 1 to maybe change the plugin, but they changed their mind and went forward to step 2. Since the first step knows that the plugin did not change (the second step's state says it is still the same plugin), it can initialize the connection information form with the previous data instead of having the user fill it out again.

    This approach offers improved user experience, but makes it more difficult to write a TypeScript type to correctly represent this scenario.

TypeScript representation of the state

Individual steps state

Let's assume we have 3 steps with the following types describing their state:

interface SelectPluginStepState {
  initialExternalPluginName: string | undefined;
}

interface ConnectionDetailsData {
  credentialId: string;
  paramsFormData: Record<string, unknown>;
}

interface ConnectionDetailsStepState {
  externalPluginName: string;
  initialFormState: ConnectionDetailsData | undefined;
}

interface PreviewStepState extends ConnectionDetailsData {
  externalPluginName: string;
}

Complete wizard steps state

The final state of the wizard, after all the steps are filled in, looks as follows:

interface ConnectExternalPluginWizardStepsState {
  selectPlugin: SelectPluginStepState;
  connectionDetails: ConnectionDetailsStepState;
  preview: PreviewStepState;
}

What we want is a TypeScript type that takes in this complete wizard steps state and a step name (e.g. connectionDetails) and returns the following type:

interface WizardStepsStateAtConnectionDetailsStep {
  selectPlugin: SelectPluginStepState;
  connectionDetails: ConnectionDetailsStepState;
  preview?: PreviewStepState;
}

Notice that the state for the first 2 steps is required, but the state of the third step is optional - the user could have visited that step and went back.

Wizard state at a given step

To make it work, we need the following WizardStateAtSomeStep type:

/**
 * Returns wizard steps state at step `StepToLookFor`.
 * Assumes that a wizard at a particular step has all the state for the
 * previous step and the current step, and could have state for the following
 * steps (if the user visited them and then went back to a previous step).
 *
 * The state of steps before `StepToLookFor` is required,
 * because these steps were already completed.
 *
 * The state of the `StepToLookFor` is also required.
 *
 * The state of steps after `StepToLookFor` is optional. These steps could
 * have been visited by the user and then the user may have came back to an
 * earlier step. These steps may also not have been visited by the user at all.
 *
 * In other words, for a wizard with steps 1..N, for the current step M, this
 * type assumes that the state for steps 1..M (inclusive) are **required**, but
 * the state for steps (M+1)..N is optional.
 *
 * Relies on `RemainingSteps` for the order of steps.
 */
type WizardStateAtSomeStep<
  CompleteWizardStepsState,
  AccumulatedWizardState,
  /** Whether `StepToLookFor` was previously matched in the `RemainingSteps` array */
  StepPreviouslyMatched extends boolean,
  RemainingSteps,
  StepToLookFor extends keyof CompleteWizardStepsState
> =
  // Implementation notes:
  // The type recursively analyzes `RemainingSteps` one by one.
  // For each step, we either `Pick` that step's state as required or as
  // `Partial`.
  // That depends on whether the `StepPreviouslyMatched` (or the `CurrentStep`
  // matches) `StepToLookFor`.
  RemainingSteps extends [
    infer CurrentStep extends keyof CompleteWizardStepsState,
    ...infer RestSteps
  ]
    ? StepToLookFor extends CurrentStep
      ? WizardStateAtSomeStep<
          CompleteWizardStepsState,
          AccumulatedWizardState & Pick<CompleteWizardStepsState, CurrentStep>,
          true,
          RestSteps,
          StepToLookFor
        >
      : StepPreviouslyMatched extends true
      ? WizardStateAtSomeStep<
          CompleteWizardStepsState,
          AccumulatedWizardState &
            Partial<Pick<CompleteWizardStepsState, CurrentStep>>,
          true,
          RestSteps,
          StepToLookFor
        >
      : WizardStateAtSomeStep<
          CompleteWizardStepsState,
          AccumulatedWizardState & Pick<CompleteWizardStepsState, CurrentStep>,
          false,
          RestSteps,
          StepToLookFor
        >
    : StepPreviouslyMatched extends true
    ? AccumulatedWizardState
    : // NOTE: this branch is hit when `CurrentStep` does not match any
      // `OrderedConnectExternalPluginWizardSteps`. This should never happen due
      // to TS type constraints, but the ternary operators still expect some
      // expression here.
      RemainingSteps;

Let's now use these types for our wizard:

/** A helper type to make sure all tuple elements are assignable to `T`. */
type OrderedTuple<T, Tuple extends T[]> = Tuple;

/**
 * Simplifies a type to its most basic representation.
 *
 * @see https://github.com/ianstormtaylor/superstruct/blob/41d7fdd09a0c0f0291b03357e487420d4ece6b56/src/utils.ts#L319-L325
 */
type Simplify<T extends Record<string, unknown>> = {
  [Key in keyof T]: T[Key];
} & {};

/** Externals wizard step names */
export type ConnectExternalPluginWizardStep =
  keyof ConnectExternalPluginWizardStepsState;

/** A helper type to make sure all tuple elements are assignable to `T`. */
type OrderedTuple<T, Tuple extends T[]> = Tuple;

/** Externals wizard step names in the order of appearance */
type OrderedConnectExternalPluginWizardSteps = OrderedTuple<
  ConnectExternalPluginWizardStep,
  ["selectPlugin", "connectionDetails", "preview"]
>;

export type ConnectExternalPluginWizardStepsStateAtStep<
  Step extends ConnectExternalPluginWizardStep
> =
  // NOTE: using `Simplify` resolves the union into an object
  // which makes it easier to read and work with.
  Simplify<
    WizardStateAtSomeStep<
      ConnectExternalPluginWizardStepsState,
      // NOTE: Record<string, never> does not intersect well with other objects.
      {},
      false,
      OrderedConnectExternalPluginWizardSteps,
      Step
    >
  >;

We had to declare the order of wizard steps (because it is not obvious from the complete wizard state object). We can now use the ConnectExternalPluginWizardStepsStateAtStep type to get the wizard state type for a given step, just like we wanted:

type WizardStepsStateAtConnectionDetailsStep =
  ConnectExternalPluginWizardStepsStateAtStep<"connectionDetails">;

TypeScript evaluates it to:

type WizardStepsStateAtConnectionDetailsStep = {
  selectPlugin: SelectPluginStepState;
  connectionDetails: ConnectionDetailsStepState;
  preview?: PreviewStepState | undefined;
};

which is just what we needed.

Possible wizard state variants

One more type that may come in handy is describing all possible variants of state that a wizard can be in:

  • the current step name
  • the state at this step

This can be done with the following mapped object type:

/**
 * Variants of the entire wizard's state.
 * There is a separate variant of the wizard's state for each step.
 *
 * @see WizardStateAtSomeStep
 */
export type ConnectExternalPluginWizardState = {
  [Step in ConnectExternalPluginWizardStep]: {
    step: Step;
    stepsState: ConnectExternalPluginWizardStepsStateAtStep<Step>;
  };
}[ConnectExternalPluginWizardStep];

TypeScript resolves that to a union of objects, one wizard step represented by one object. You can use that type to hold the information about the wizard, for instance in a React hook:

import { useState } from "react";
const [wizardState, setWizardState] =
  useState<ConnectExternalPluginWizardState>({
    step: "selectPlugin",
    stepsState: {
      selectPlugin: {
        initialExternalPluginName: undefined,
      },
    },
  });

TypeScript playground

You can play around with the code from this article live in a TypeScript playground.

Conclusion

Wizards come in different shapes and sizes. I hope I captured the requirements that make it possible to implement most wizard types and offer the best user experience possible.

The remark about future steps state being optional is non-trivial to arrive at and makes the implementation more involved, but allows offering superior user experience to only remembering the previous steps state.