Flatten a nested data property into its parent object type at every depth, using Omit, an intersection and a mapped type that rebuilds one flat result.
API responses love to wrap the interesting fields in a data envelope. This challenge unwraps them in the type system.
ReducedNesting<T> walks an object type and, wherever a property is an object with a data key, lifts the contents of data up one level and drops the data key itself. The rewrite has to happen at every depth, so a data buried three objects down gets the same treatment as one at the top.
interface Model {
id: number
extension: {
id: number
data: {
id: number
prop1: any
prop2: any
}
}
}
// ReducedNesting<Model> is expected to be
type ReducedModel = {
id: number
extension: {
id: number
prop1: any
prop2: any
}
}Assuming you have an object of type
{
id: number
foo: {
id: number
data: {
id: number
prop1: any
prop2: any
}
}
}and we want to remove the nesting of the data to get a type
{
id: number
foo: {
id: number
prop1: any
prop2: any
}
}Should also work with deeper levels of nesting e.g.
interface DeepModel {
id: number
foo: {
id: number
data: {
id: number
prop1: any
prop2: any
bar: {
id: number
data: {
id: number
prop1: any
prop2: any
}
}
}
}
}should be reduced to
interface ReducedDeepModel {
id: number
foo: {
id: number
prop1: any
prop2: any
bar: {
id: number
prop1: any
prop2: any
}
}
}View on GitHub: https://tsch.js.org/20744
Change the following code to make the test cases pass (no type check errors).
The solution splits into two types, one per job:
type Inline<T> = T extends { data: infer D } ? Inline<Omit<T, 'data'> & D> : T
type ReducedNesting<T> = T extends object
? { [K in keyof Inline<T>]: ReducedNesting<Inline<T>[K]> }
: TInline fixes a single object. ReducedNesting applies it everywhere.
Start with the object that actually needs work:
[object Object]T extends { data: infer D } is a conditional type with an inference slot. It asks "does T have a data property?", and if it does, D is bound to whatever that property holds. Here D becomes { id: number; prop1: any; prop2: any }.
Now build the replacement. Omit<T, 'data'> is everything except the envelope key, and intersecting it with D adds the lifted members back in:
type WithoutData = Omit<Leaf, 'data'> // { id: number }
type Lifted = WithoutData & { id: number; prop1: any; prop2: any }
type Keys = keyof Lifted // 'id' | 'prop1' | 'prop2'Inline then calls itself on that result. If D had carried its own data key, that key is now sitting at the top of Lifted and the next pass removes it too. The recursion stops as soon as the conditional fails, which is the moment there is no data key left, so Inline<{ id: number }> returns immediately.
Lifted is correct but it is an intersection, and Equal in the tests compares types structurally rather than by what they simplify to on screen. { id: number } & { id: number; prop1: any; prop2: any } is not the same type as { id: number; prop1: any; prop2: any } as far as that check is concerned.
A mapped type solves it. Iterating over keyof Inline<T> and indexing back into Inline<T> produces a fresh object type with those three keys and nothing else:
{ [K in keyof Inline<T>]: Inline<T>[K] }
// { id: number; prop1: any; prop2: any }Indexing does the merging for you. Inline<Leaf>['id'] is number & number, which collapses to number.
The property values need the same treatment, so instead of Inline<T>[K] the mapped type writes ReducedNesting<Inline<T>[K]>. That is where the deep case gets handled: foo is flattened, then each of its properties goes through the whole process again, so bar loses its own data wrapper on the next turn.
The outer T extends object guard is what stops the recursion at leaves. ReducedNesting<number> finds no object, takes the false branch, and hands number straight back. Without that guard the mapped type would try to run over a primitive.
Reading the two types together for Model: Inline<Model> is Model itself, because the top level has no data key. The mapped type then visits id (a primitive, returned as-is) and extension, and ReducedNesting<extension> is the Leaf walkthrough above.
data key pass through Inline untouched, so id stays where it is.prop1: any survives as any. A conditional type checked against any evaluates to the union of both branches, and a union containing any is any.bar object inside a data envelope, which is itself inside another data envelope. Flattening the outer one moves bar up before bar is ever visited, so the order of the two rewrites takes care of itself.id that exists on both the wrapper and the data object, resolve through the intersection rather than producing two entries.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges