skip to Main Content

Consider this object:

export const cypher = {
  Foo: {
    get Bar() {
      return 'Foo_Bar';
    },
    get Baz() {
      return 'Foo_Baz';
    },
  },
  get Qux() {
    return 'Qux';
  },
  Lan: {
    Rok: {
      get Saz() {
        return 'Lan_Rok_Saz';
      },
    },
  },
};

You can see the pattern: this is a "tree" where each leaf would return a string of the concatanated names of its entire branch, i.e.

console.log(cypher.Lan.Rok.Saz) // Lan_Rok_Saz

This manual object is strictly typed, so I get intellisense nicely.
I would like to now create some constructor, that accepts an object of a type such as:

interface Entries {
  [key: string]: (string | Entries)[];
}

And returns an object with the structure as the above cypher, such that TS would be able to intellisense.
The internal implementation isn’t necesarrily important, but the usage of X.Y.Z is definitely a priority.

So far I’ve tried a recursive function that defines properties based on values:

interface Entries {
  [key: string]: (string | Entries)[];
}

const mockEntries: (string | Entries)[] = ['Gat', 'Bay', {
  Foo: ['Bar', 'Baz', { Cal: ['Car'] }],
}];

function buildCypherFromEntries(entries: (string | Entries)[], parentName: string, cypher: Record<string, any> = {}) {
  entries.forEach(entry => {
    if (typeof entry === 'string') {
      Object.defineProperty(cypher, entry, {
        get() { return (parentName ? `${parentName}_${entry}` : entry); },
      });
    } else {
      Object.entries(entry).forEach(([key, furtherEntries]) => {
        cypher[key] = {};
        const furtherParentName = parentName ? `${parentName}_${key}` : key;
        buildCypherFromEntries(furtherEntries, furtherParentName, cypher[key]);
      })    

    }
  })
  return cypher;
}

const c = buildCypherFromEntries(mockEntries, '');
console.log(c.Gat) // Gat
console.log(c.Bay) // Bay
console.log(c.Foo.Bar); // Foo_Bar
console.log(c.Foo.Cal.Car) // Foo_Cal_Car

This works, but does not give any intellisense.
It’s also not perfect as it does not support top-level leaves that turn into getters.
I also tried doing this in class form but again the typing confounds me.

2

Answers


  1. In order for this to possibly work, you can’t annotate mockEntries as having type (string | Entries)[], since that completely throws away any more specific information the compiler might infer from the initializer. Instead you should use a const assertion to get the most specific information about the value as possible, especially the string literal types:

    const mockEntries = ['Gat', 'Bay', {
      Foo: ['Bar', 'Baz', { Cal: ['Car'] }],
    }] as const;
    
    /* const mockEntries: readonly ["Gat", "Bay", {
        readonly Foo: readonly ["Bar", "Baz", {
            readonly Cal: readonly ["Car"];
        }];
    }] */
    

    Now we can proceed. Note that the arrays are readonly tuple types; we don’t care much about the ordering or the readonly-ness (although it’s probably for the best that you don’t alter that information), but that does mean things will be easiest if we allow readonly arrays in our types. To that end, let’s redefine Entries as:

    interface Entries {
      [key: string]: readonly (string | Entries)[];
    }
    

    One approach looks like this:

    declare function buildCypherFromEntries<T extends readonly (string | Entries)[], U = {}>(
      entries: T, parentName: string, cypher?: U
    ): U & BuildCypherFromEntryElements<T>;
    
    type BuildCypherFromEntryElements<T> =
      T extends readonly any[] ?
      BuildCypherFromEntries<UnionToIntersection<StrToObj<T[number]>>> :
      string
    
    type StrToObj<T> = 
      T extends string ? { [P in T]: undefined } : T;
    
    type UnionToIntersection<T> =
      (T extends infer U ? ((x: U) => void) : never) extends 
      ((x: infer I) => void) ? I : never;
    
    type BuildCypherFromEntries<T> = {
      -readonly [K in keyof T]: BuildCypherFromEntryElements<T[K]>
    } extends infer O ? { [K in keyof O]: O[K] } : never;
    

    We need buildCypherFromEntries() to be generic in the type T of the entries argument, plus I guess the type U of the cypher argument which we’ll default to the empty object type {}. The return type of the function is the intersection of the cypher type U with the type BuildCypherFromEntryElements<T>. The U part just keeps any other properties that might be in U, while the main work happens inside BuildCypherFromEntryElements.

    So BuildCypherFromEntryElements<T> takes a T which is expected to be an array but might also be "empty" if we’ve recursed down into a single string instead of an object. If it’s not an array we just output string as the value type. If it is an array, then we first apply StrToObj<> to the union of its elements, so that strings are turned into something with the right key and a value we don’t care about (e.g., we turn 'Bar' | 'Baz' | { Cal: ['Car'] } into {Bar: undefined} | {Baz: undefined} | {Cal: ['Car']}). This works via distributive conditional type. Then we apply UnionToIntersection<> to that (see Transform union type to intersection type for implementation information), to turn the union into an intersection (e.g., {Bar: undefined} & {Baz: undefined} & {Cal: ['Car']}. And finally we apply BuildCypherFromEntries<T> to that.

    And BuildCypherFromEntries<T> is a mapped type over its input that just applies BuildCypherFromEntryElements<> to each property. Well I also use a trick to turn the resulting nested intersection type into something pretty (see How can I see the full expanded contract of a Typescript type? for implementation info).


    Let’s test it out:

    const c = buildCypherFromEntries(mockEntries, '');
    /* const c: {
        Foo: {
            Cal: {
                Car: string;
            };
            Bar: string;
            Baz: string;
        };
        Gat: string;
        Bay: string;
    } */
    

    That looks like what you wanted!

    Playground link to code

    Login or Signup to reply.
  2. jcalz already explained all the why’s way better than I can.

    I just want to add a slightly different implementation:

    interface Entries {
      [key: string]: readonly (string | Entries)[];
    }
    
    /** {foo: 1} | {bar: 2} -> {foo: 1, bar: 2} */
    type Merge<T> = (T extends object ? (x: T) => void : never) extends ((x: infer R) => void) ? { [K in keyof R]: R[K] } : never;
    
    type CypherFromEntries<
      T extends string | Entries | Entries[string], 
      Prefix extends string = ""
    > =
      T extends string ? { readonly [K in T]: `${Prefix}${T}` } :
      T extends Entries ? { -readonly [K in string & keyof T]: CypherFromEntries<T[K], `${Prefix}${K}_`> } :
      T extends Entries[string] ? Merge<CypherFromEntries<T[number], Prefix>> :
      never;
    
    
    // sadly I have to use excessive Force (any) so that TS doesn't 
    // get hung up in its "excessively deep and possibly infinite" typings.
    function cypherFromEntries<
      const T extends string | Entries | Entries[string],
      const Prefix extends string = ""
    >(value: T, prefix: Prefix = "" as Prefix): CypherFromEntries<T, Prefix> {
      const _ = (acc: any, item: string | Entries) => {
        if (typeof item === "string") {
          acc[item] = `${prefix}${item}`;
        } else {
          for (let key in item) {
            acc[key] = (cypherFromEntries as any)(item[key], `${prefix}${key}_`);
          }
        }
        return acc;
      }
    
      return Array.isArray(value) ?
        (value as Entries[string]).reduce(_, {} as any) :
        _({}, value as string | Entries);
    }
    

    Example:

    const c = cypherFromEntries(['Gat', 'Bay', {
      Foo: ['Bar', 'Baz', { Cal: ['Car'] }],
    }]);
    /*
    const c: {
        readonly Gat: "Gat";
        readonly Bay: "Bay";
        Foo: {
            readonly Bar: "Foo_Bar";
            readonly Baz: "Foo_Baz";
            Cal: {
                readonly Car: "Foo_Cal_Car";
            };
        };
    }
    */
    

    TS Playground link

    One important difference to your code: I use a prefix where you use the parentName. Basically

    prefix === parentName ? `${parentName}_` : "";
    
    // that way I can replace 
    
    parentName ? `${parentName}_${key}` : key
    
    // with
    
    prefix + key
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search