Search code examples
javascripttypescript

Typescript dynamic type literals


I recently stumbled upon interesting problem. I'm trying to create a vuejs wizard component which accepts configuration objects. Demo type looks like this:

type WizardConfiguration = {
  steps: Array<{
      name: string,
      fields: {
        fieldName: string;
      }[]
    watchers: (FIELD NAMES ARR) => void
  }>,
  globalWatchers: (ENTIRE STATE FIELD NAMES ARR) => void
}

The problems stands in the watchers line. I want watchers callback to accept array of field names which were specified in the fields array. Example would look like this:

const config: WizardConfiguration = {
  steps: [
   {
      name: "Step 1",
      fields: [
         { fieldName: "Field 1 1" },
         { fieldName: "Field 1 2" }
      ],
      watchers: ([HERE I GET A INTELLISENSE of "Field 1 1", "Field 1 2"]) => {
      // do smth
      }
   },
   {
      name: "Step 2",
      fields: [
         { fieldName: "Field 2 1" },
         { fieldName: "Field 2 2" }
      ],
      watchers: ([HERE I GET A INTELLISENSE of "Field 2 1", "Field 2 2"]) => {
      // do smth
      }
   },
  ],
  globalWatchers: ([HERE I GET INTELLISENSE OF ENTIRE WIZARD STATE - "Field 1 1", "Field 1 2", "Field 2 1", "Field 2 2"])
};

Is something like this event possible in the current state of ts?

Thanks

EDIT 1: Wizard will preserve the state as reative object. I'd want to go with the flattest object structure as possible, namely. each fieldName in the wizard will be represented by [fieldName]: value in the state object.


Solution

  • There is no specific type in TypeScript corresponding to your requirements. Instead, the closest you can get is to make a generic type like WizardConfiguration<T extends any[][]> where T is meant to be a tuple of tuples of strings. The idea is that WizardConfiguration<[["a","b"],["c","d"]]> would end up describing a configuration with two steps, the first step having fields "a" and "b" in that order, and the second step having fields "c" and "d" in that order.

    The type could look like:

    interface WizardConfiguration<T extends string[][]> {
       steps: { [I in keyof T]: Step<T[I]> },
       globalWatchers: (args: TupleFlat<T>) => void;
    }
    

    where we have yet to define Step and TupleFlat.

    The globalWatches property is a function whose parameter type is TupleFlat<T>, so evidently it should flatten the tuple of tuples into a single one. We want TupleFlat<[["a","b"],["c","d"]]> to be ["a","b","c","d"]. Here's one way to write TupleFlat:

    type TupleFlat<T extends any[][], A extends T[number][number][] = []> =
       T extends [infer F extends any[], ...infer R extends any[][]] ?
       TupleFlat<R, [...A, ...F]> : A;
    

    That's a tail-recursive conditional type using variadic tuple types to join tuples.

    The steps property of WizardConfiguration<T> is a mapped array type over the tuple-of-strings elements of T, where we take each element T[I] and map it to Step<T[I]>.., that is, steps for WizardConfiguration<[["a","b"],["c","d"]]> becomes [Step<["a","b"]>,Step<["c","d"]>]. So let's define Step:

    interface Step<T extends string[]> {
       name: string;
       fields: { [I in keyof T]: { fieldName: T[I] } }
       watchers: (args: T) => void;
    }
    

    That defines a single step. Here we have another mapped array type for field, so that for each string element of T we have an object with a fieldName of that property. And then watchers is a method with a parameter of type T.


    Now, that means if you want to use the type you need to specify that tuple-of-tuples-of-strings:

    const config: WizardConfiguration<[
      ["Field 1 1", "Field 1 2"],
      ["Field 2 1", "Field 2 2"]
    ]> = {
      steps: [
        {
          name: "Step 1",
          fields: [ { fieldName: "Field 1 1" }, { fieldName: "Field 1 2" }],
          watchers: ([a, b]) => {
            a // (parameter) a: "Field 1 1"
            b // (parameter) b: "Field 1 2"          
          }
        },
        {
          name: "Step 2",
          fields: [ { fieldName: "Field 2 1" }, { fieldName: "Field 2 2" }],
          watchers: ([c, d]) => {
            c // (parameter) c: "Field 2 1"
            d // (parameter) d: "Field 1 2"          
          }
        },
      ],
      globalWatchers: ([e, f, g, h]) => {
        e // (parameter) e: "Field 1 1"
        f // (parameter) f: "Field 1 2"          
        g // (parameter) g: "Field 2 1"
        h // (parameter) h: "Field 2 2"          
      }
    }
    

    That works exactly as advertised, but it's redundant because it requires you to write, e.g., "Field 1 1" both in the object literal and in the type.

    You can avoid that by writing some generic helper functions to infer the type arguments for you. It would be great if I could write a single wizardConfiguration() function which would take the above object literal and infer the type properly, but this requires inference from nested mapped types and it doesn't seem to function as desired. There are possibly other appropaches, but one easy way to do it is to introduce another helper function step() for each of the steps. Like this:

    const wizardConfiguration = <const T extends string[][]>(w: WizardConfiguration<T>) => w;
       
    const step = <const T extends string[]>(s: Step<T>) => s;
    

    These functions don't really serve any purpose except to help with type inference. You write const c = wizardConfiguration({⋯}) instead of const c: WizardCondifiguration<[["a","b"],["c","d"]]> = {⋯}.

    Let's test it:

    const config = wizardConfiguration({
      steps: [
        step({
          name: "Step 1",
          fields: [{ fieldName: "Field 1 1" }, { fieldName: "Field 1 2" }],
          watchers: ([a, b]) => {
            a // (parameter) a: "Field 1 1"
            b // (parameter) b: "Field 1 2"          
          }
        }),
        step({
          name: "Step 2",
          fields: [{ fieldName: "Field 2 1" }, { fieldName: "Field 2 2" }],
          watchers: ([c, d]) => {
            c // (parameter) c: "Field 2 1"
            d // (parameter) d: "Field 1 2"          
          }
        }),
      ],
      globalWatchers: ([e, f, g, h]) => {
        e // (parameter) e: "Field 1 1"
        f // (parameter) f: "Field 1 2"          
        g // (parameter) g: "Field 2 1"
        h // (parameter) h: "Field 2 2"          
      }
    })
    // const config: WizardConfiguration<[
    //   ["Field 1 1", "Field 1 2"], 
    //   ["Field 2 1", "Field 2 2"]
    // ]>
    

    That works. The string literal types "Field 1 1", etc., are all just inferred from the initializer. Yes, you have those intermediate step() calls in there, but hopefully it's not too cumbersome to use.


    That's the answer to the question as asked. Once you use helper functions you might consider switching to a builder pattern instead, which might look like

    const config = WizardConfigBuilder
      .addStep("Step 1", [{ fieldName: "Field 1 1" }, { fieldName: "Field 1 2" }], ([a, b]) => { })
      .addStep("Setp 2", [{ fieldName: "Field 2 1" }, { fieldName: "Field 2 2" }], ([c, d]) => { })
      .addGlobalWatchers(([e, f, g, h]) => { });
    

    where one possible implementation of WizardConfigBuilder might be

    class WizardConfigBuilder<T extends string[][] = []> {
      constructor(public steps: { [I in keyof T]: Step<T[I]> }) { }
      addStep<U extends string[]>(
        name: string,
        fields: { [I in keyof U]: { fieldName: U[I] } },
        watchers: (args: U) => void
      ): WizardConfigBuilder<[...T, U]> {
        return new (WizardConfigBuilder as any)([...this.steps, { name, fields, watchers }]);
      }
      addGlobalWatchers(globalWatchers: (args: TupleFlat<T>) => void): WizardConfiguration<T> {
        return { steps: this.steps, globalWatchers };
      }
      static addStep: WizardConfigBuilder<[]>["addStep"] = (...args) => new WizardConfigBuilder([]).addStep(...args)
    }
    

    but this answer is already very long and this is somewhat out of scope. I'll leave off the detailed explanation; it's just here as proof of concept that you might find a builder a more friendly experience.

    Playground link to code