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