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