5

Say I have the following interface:

interface Foo {
  foo: string
  bar?: number
  nested: {
    foo: string,
    deeplyNested: {
      bar?: number
    }
  }
  union: string | {
    foo: string,
    bar?: number
  }
}

Now I need a type to convert this interface to a flattened version with the full path names in the keys like this:

interface FooFlattened {
  foo: string
  bar?: number
  "nested.foo": string
  "nested.deeplyNested.bar"?: number

  // if union is a string:
  union: string

  // if union is an object:
  "union.foo": string
  "union.bar"?: number
}

The type should work for any level of nesting and any amount of union members.

I already found a related question here but that one's only a very basic interface without unions or optional paramters.

DaDo
  • 443
  • 1
  • 6
  • 20
  • I don't think you can do this, sorry. – jered Sep 07 '21 at 22:46
  • This is almost a duplicate of [this question](//stackoverflow.com/questions/65919016) except you have some slightly different requirements. The code there with a tweak gives [this](https://tsplay.dev/W4pg7W) which is pretty close to what you want. I might be able to get closer, but even so, this question is pretty duplicatey (that's a word, right?) unless you can justify an important distinction between the two (e.g., what's the use case for having `{union: string}` and `{"union.foo": string}` appear at the same time and not as a union?). – jcalz Sep 08 '21 at 02:25
  • @jcalz your tweaked solution is nearly the thing I need, however for some reason the `deeplyNested` property got lost and is missing on the final result. What happened to it? – DaDo Sep 08 '21 at 07:21
  • Okay if I get a chance I'll see if I can come up with something better (although possibly not any better than @CRice's solution). – jcalz Sep 08 '21 at 12:22
  • Is [this](https://tsplay.dev/wQKdjm) what you want? I'd really like to see some more use cases though... I don't want to post the answer and then see that it doesn't work for some use case not posted here. – jcalz Sep 08 '21 at 15:09
  • @jcalz I tried it with a complex interface from my project [here](https://tsplay.dev/N52Ydm) and got an error `'x' is referenced directly or indirectly in its own type annotation.(2502).` I also realised that I'm using object types in my interface such as `Date` and `Buffer` but you can ignore them/remove them since I'll replace those with primitives soon. – DaDo Sep 08 '21 at 17:41
  • I can probably remove that error (`any` confused it) but I don't understand at all what you expect to see with an array. Like, what key should `Flatten<{foo: Array<{bar: string}>}>` have in it? Dotted paths into arrays are... like... what are you looking for, here? – jcalz Sep 08 '21 at 19:02
  • Like, how about [this](https://tsplay.dev/NDGjjw)? Please put me out of my misery soon and let me know what you want to see for your complex interface (and maybe put both input and output definitions into the question text proper) – jcalz Sep 08 '21 at 20:39
  • @jcalz I'm sorry for your suffering.... you can ignore the array too and imagine it as the type that's in it. I think your last solution is exactly what I need, you can post it as an answer. – DaDo Sep 08 '21 at 22:03

2 Answers2

8

There are a lot of ways to approach this, and in my experience they are all tricky and fiddly and fragile and have lots of crazy edge cases. See How can an object type with nested subproperties be flattened? for a similar question with a similarly caveat-filled answer.

In the end, in this case I decided to take the following approach. We can take an object type and represent it as a union of Entry elements, where an Entry has a key, value, and optional property:

type Entry = { key: string, value: any, optional: boolean };

So for a type like {a: string, b?: number} you would get {key: "a", value: string, optional: false} | {key: "b", value: number | undefined, optional: true}.

For the first step we would take the input interface like Foo and Explode it into a big union of Entry elements where deeply nested properties are converted to single keys with dotted paths. So Explode<{a: {b: string}}> would become {key: "a.b", value: string, optional: false}. This does the heavy lifting of the algorithm, and we have to decide things like how optional properties propagate, how unions propagate, etc.

A possible definition of Explode could look like this:

type Explode<T> =
    T extends object ? { [K in keyof T]-?:
        K extends string ? Explode<T[K]> extends infer E ? E extends Entry ?
        {
            key: `${K}${E['key'] extends "" ? "" : "."}${E['key']}`,
            value: E['value'],
            optional: E['key'] extends "" ? {} extends Pick<T, K> ? true : false : E['optional']
        }
        : never : never : never
    }[keyof T] : { key: "", value: T, optional: false }

If we Explode a non-object type we use a blank key; otherwise we recursively Explode all the properties of the object and then concatenate keys using template literal types, and decide something with optional properties (I think I made it so that {a: {b?: string}} would make b optional, but {a?: {b: string}} would make b required). It's a mess.

Oh and I wasn't sure what to do with arrays; I decided to just use "0" as the key to represent an array index, and so I renamed Explode<T> to _Explode<T> and then define Explode<T> in terms of it:

type Explode<T> = _Explode<T extends readonly any[] ? { "0": T[number] } : T>;

Finally, once we have the full exploded union of Entry elements, we can Collapse them together into a single object type:

type Collapse<T extends Entry> = (
    { [E in Extract<T, { optional: false }> as E['key']]: E['value'] }
    & Partial<{ [E in Extract<T, { optional: true }> as E['key']]: E['value'] }>
) extends infer O ? { [K in keyof O]: O[K] } : never

I'm using key remapping via as to iterate over each element E of the union T of Entry objects; each key is E['key'] and each value is E['value']. By partitioning the union into those with true and false values for the optional property, we can produce an output type with optional and required properties, respectively.

And, finally, Flatten<T> is just what you do when you Explode something into Entry objects and then Collapse those objects:

type Flatten<T> = Collapse<Explode<T>>

Here's how it works on your example here:

type FooFlattened = Flatten<Foo>
/* type FooFlattened = {
  foo: string;
  "nested.foo": string;
  union: string;
  "union.foo": string;
  bar?: number | undefined;
  "nested.deeplyNested.bar"?: number | undefined;
  "union.bar"?: number | undefined;
}*/

which is exactly the same type as your manually written expected output (except for order of properties, which does not affect type equality).


For something more complex, like

interface Foo {
    tmdb: number | {
        title: {
            original: string
            german?: string
        }
        budget?: number
        revenue?: number
        tagline?: string
        overview?: string
        productionCompanies?: {
            id?: number
            logoPath?: string
            name?: string
            originCountry?: string
        }[]
        releaseDate?: string
        genres?: string[]
        runtime?: number
        poster?: string | {
            data: { sample: any } 
            contentType: string
        }
    }
    rating: { ch: number; rt: number } | { total: number }
    dateSeen?: Date
    fsk?: number
    mm?: boolean
}

we get

type FlattenedFoo = Flatten<Foo>
/* type FlattenedFoo = {
    tmdb: number;
    "tmdb.title.original": string;
    "tmdb.genres.0": string;
    "tmdb.poster.data.sample": any;
    "tmdb.poster.contentType": string;
    "rating.ch": number;
    "rating.rt": number;
    "rating.total": number;
    "tmdb.title.german"?: string | undefined;
    "tmdb.budget"?: number | undefined;
    "tmdb.revenue"?: number | undefined;
    "tmdb.tagline"?: string | undefined;
    "tmdb.overview"?: string | undefined;
    "tmdb.productionCompanies"?: undefined;
    "tmdb.productionCompanies.0.id"?: number | undefined;
    "tmdb.productionCompanies.0.logoPath"?: string | undefined;
    "tmdb.productionCompanies.0.name"?: string | undefined;
    "tmdb.productionCompanies.0.originCountry"?: string | undefined;
    "tmdb.releaseDate"?: string | undefined;
    "tmdb.genres"?: undefined;
    "tmdb.runtime"?: number | undefined;
    "tmdb.poster"?: string | undefined;
    dateSeen?: undefined;
    fsk?: number | undefined;
    mm?: boolean | undefined;
} */

which is reasonable, I think.


Of course there are likely edge cases where this will do something you don't want. They might be addressed by changing how Explode works, or maybe nothing you do will work for all of your intended use cases and you'll have to decide either to give up some of your use cases, or give up on Flatten.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you so much!. This should 1. be a native utility type or 2. be included in an "essential typescript types" package – Janis Jansen Mar 29 '22 at 11:50
  • This is some amazing sorcery! You did mention that it probably won't work in all cases, and indeed, it seems to strip out dates (and probably other complex objects). I was able to fix this by adding `T extends Date ? { key: "", value: T, optional: false } :` before the first line of `Explode`. – kael Dec 10 '22 at 16:00
3

First we can work to create a type that contains a union of the valid dotted paths for your object. Thankfully we already have an excellent answer giving a method to do so right here.

Using the Leaves type from that answer, we can construct your union of paths:

type DottedFooPaths = Leaves<Foo>;
// type DottedFooPaths = "foo" | "bar" | "union" | "nested.foo" | "nested.deeplyNested.bar" | "union.foo" | "union.bar"

Now we need to create a new object using those paths as the keys, and associate each path to it's corresponding value type in the original object. To accomplish that, we can perform the dotted-path conversion in reverse, and use that to index into our original type recursively:

type FollowPath<T, P> = P extends `${infer U}.${infer R}` ? U extends keyof T ? FollowPath<T[U], R> : never : P extends keyof T ? T[P] : never;

// For example, this might produce:
type FooDotBar = FollowPath<Foo, "bar"> // number | undefined

Then, if we construct a type where each key is one of the members of Leaves<Foo>, we can use FollowPath to extract the corresponding value type:

type FlattenedFoo = {
    [K in Leaves<Foo>]: FollowPath<Foo, K>
}

// Produces:
// type FlattenedFoo = {
//     foo: string;
//     bar: number | undefined;
//     union: string | {
//         foo: string;
//         bar?: number | undefined;
//     };
//     "nested.foo": string;
//     "nested.deeplyNested.bar": number | undefined;
//     "union.foo": never;
//     "union.bar": never;
// }

Which is almost your desired result, but it loses the optional modifiers on the keys; although I'm uncertain if there even is a way to keep the optional modifiers.

CRice
  • 29,968
  • 4
  • 57
  • 70
  • Addendum: Building this into a easier to use generic type, ala `Flattened = {[K in Leaves]: FollowPath}`, doesn't seem to work. I'm not sure why that fails but the direct approach (where we manually substitute `Foo` for `T`) works fine. If anyone knows why, please comment. – CRice Sep 07 '21 at 23:11
  • Another problem with this solution is that `union` is still a union type `string | { ... }` in the result so the nested object is still there. I need the entire interface to be a union of all possible union objects inside the interface (see jcalz' comment on the question). – DaDo Sep 08 '21 at 08:08