1

Is there a way to flatten this type:

type MySchema = {
  fields: {
    hello: {
      type: 'Group'
      fields: {
        world: {
          type: 'Group'
          fields: { yay: { type: 'Boolean' } }
        }
      }
    }
    world: { type: 'Boolean' }
  }
}

Into this:

type MyFlattenedSchema = {
  hello: { type: 'Group' }
  'hello.world': { type: 'Group' }
  'hello.world.yay': { type: 'Boolean' }
  world: { type: 'Boolean' }
}

I've been trying to find a solution for two days now, and all I get is a flattened union:

type FSchema = { type: string; fields?: Record<string, FSchema> }

type GetPathAndChilds<
  T extends Record<string, FSchema>,
  PK extends string | null = null
> = {
  [FN in keyof T & string]-?: {
    path: PK extends string ? `${PK}.${FN}` : `${FN}`
    type: T[FN]['type']
    // config: T[K]
    childs: T[FN] extends { fields: Record<string, FSchema> }
      ? GetPathAndChilds<
          T[FN]['fields'],
          PK extends string ? `${PK}.${FN}` : `${FN}`
        >
      : never
  }
}[keyof T & string]

type FlattenToUnion<T extends { path: string; type: string; childs: any }> =
  T extends {
    path: infer P
    type: infer U
    childs: never
  }
    ? { [K in P & string]: { type: U } }
    : T extends { path: infer P; type: infer U; childs: infer C }
    ? { [K in P & string]: { type: U } } | FlattenToUnion<C>
    : never

type MySchemaToUnion = FlattenToUnion<GetPathAndChilds<TestSchema['fields']>>
//   | { hello: { type: 'Group' } }
//   | { 'hello.world': { type: 'Group' } }
//   | { 'hello.world.yay': { type: 'Boolean' } }
//   | { world: { type: 'Boolean' } }

From what I found on stackoverflow, I'm getting the error 'Type instantiation is excessively deep and possibly infinite'.

smsh
  • 13
  • 3
  • Look for a type that transforms a union into a intersection. – kelsny Mar 19 '23 at 15:12
  • These sorts of deeply-nested type operations always have crazy edge cases, so any solution should be tested thoroughly against use cases you care about. For example, does [this approach](https://tsplay.dev/w1E5Xw) meet your needs? If so I could maybe write up an answer explaining; if not, what specifically goes wrong with it? – jcalz Mar 19 '23 at 16:18

1 Answers1

0

In my experience, these sorts of deeply-nested type operations always have crazy edge cases (what happens with optional properties or union types or index signatures, etc.?) where the desired behavior isn't necessarily known in advance, but the actual behavior is often wildly unexpected. That means no matter what solution you use, it needs to be tested thoroughly against your expected use cases, and be prepared for "everything works except for this one little thing" to require a complete refactoring.

So I'm going to suggest a FlattenSchema<> implementation that produces your desired output from your example input, but I can't guarantee that it will end up being useful in actual production code anywhere.


Okay, so we want to write FlattenSchema<T> that takes a T with a fields property of type object and flattens its fields. So let's write that:

type FlattenSchema<T extends { fields: object }> =
   FlattenFields<T["fields"]>;

Now we have to define FlattenFields<T> where T is some object type whose properties themselves either contain a type property (in which case we want to output that property more or less directly), or its own fields property (in which case we want to recurse FlattenSchema) or both:

type FlattenFields<T extends object> = { [K in keyof T]: (x:
   (T[K] extends { type: any } ?
      Record<K, { type: T[K]['type'] }>
      : unknown) &
   (T[K] extends { fields: object } ?
      PrependKey<K, FlattenSchema<T[K]>>
      : unknown)
) => void }[keyof T] extends (x: infer I) => void ?
   { [K in keyof I]: I[K] } : never;

That's kind of a lot of stuff. The intent here is that FlattenFields<T> should be the intersection of the results for each property of T. TypeScript makes it easy to do this for unions, but for intersections you need to use a technique as described in Transform union type to intersection type.

If T[K] has a type property then we output Record<K, {type: T[K]['type']}>, using the Record utility type. For example, if T looks like {⋯ abc: {type: X; ⋯}; ⋯} then we would output {abc: {type: X}} for this piece. Otherwise we output the unknown "top type".

If T[K] has a fields property then we output PrependKey<K, FlattenSchema<T[K]>>, using FlattenSchema recursively, and something called PrependKey<K, Z> we haven't defined yet, but whose purpose is to prepend all the keys from Z with the string in K (and a dot). For example, if T looks like {⋯ ghi: {fields: {z: {type: X}}; ⋯}, then we would output {"ghi.z": {type: X}} for this piece. Otherwise we output unknown.

Then we intersect the type-derived piece with the fields-derived piece (keeping in mind that unknown gets absorbed by intersections. And then we use the union-to-intersection to get an intersection of all the outputs from the property in I. In order to prevent ugly outputs like {abc: {type: X}} & {def: {type: Y}} & {"ghi.z": {type: Z}}, I do a single mapped type { [K in keyof I]: I[K] } to collapse them together.

Oh, and here's PrependKey:

type PrependKey<K extends PropertyKey, T> =
   { [P in Exclude<keyof T, symbol> as
      `${Exclude<K, symbol>}.${P}`]: T[P] };

which uses key remapping and template literal types to prepend to the keys.


Let's test it out:

type MySchema = {
   fields: {
      hello: {
         type: 'Group'
         fields: {
            world: {
               type: 'Group'
               fields: { yay: { type: 'Boolean' } }
            }
         }
      }
      world: { type: 'Boolean' }
   }
}
   
type MyFlattenedSchema = FlattenSchema<MySchema>;
/* type MyFlattenedSchema = {
    hello: { type: "Group"; };
    "hello.world": { type: "Group"; };
    "hello.world.yay": { type: "Boolean"; };
    world: { type: "Boolean"; };
} */

Looks good, this is exactly what you wanted! (But remember the caveat at the beginning!)

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360