How can an object type with nested subproperties be flattened?

I am interpreting this question as: “How can you take an object type in TypeScript like

type Data = {
  Id: string,
  LogicalName: string,
  VATRegistered: {
    Label: string | null,
    Value: number | null,
    SomethingElse: {
       Hello: number
    }
  }
}

and recursively flatten it to an object type like:

type TransformedData = {
  Id: string,
  LogicalName: string,
  VATRegisteredLabel: string | null,
  VATRegisteredValue: number | null,
  VATRegisteredSomethingElseHello: number
}

so that all properties are non-object types, and where each key in the new type is the concatenated key path to the resulting property?”


Let me just say that this is possible but brittle and horrifically ugly. TypeScript 4.1 gives you recursive conditional types, template literal types, and key remapping in mapped types, all of which are needed. Conceptually, to Flatten an object you want to take each property of the object and output them as-is if they are primitives or arrays, and Flatten them otherwise. To Flatten a property is to prepend the properties key to the keys of the flattened properties.

This is more or less the approach I take, but there are so many hoops you have to jump through (e.g., avoiding recursion limits, unions-to-intersections, intersections-to-single-objects, avoiding symbol keys in key concatenation, etc) that it’s hard to even begin to explain it in more detail, and there are so many edge cases and caveats (e.g., I’d expect bad things to happen with optional properties, index signatures, or property types which are unions with at least one object type member) that I’d be loath to use such a thing in production environments. Anyway, here it is in all its 🤮 glory:

type Flatten<T extends object> = object extends T ? object : {
  [K in keyof T]-?: (x: NonNullable<T[K]> extends infer V ? V extends object ?
    V extends readonly any[] ? Pick<T, K> : Flatten<V> extends infer FV ? ({
      [P in keyof FV as `${Extract<K, string | number>}${Extract<P, string | number>}`]:
      FV[P] }) : never : Pick<T, K> : never
  ) => void } extends Record<keyof T, (y: infer O) => void> ?
  O extends infer U ? { [K in keyof O]: O[K] } : never : never

Then your transformData() function could be given the following call signature (I’m using an overload and am only concerned about the behavior when you call it with no fatherName parameter. The rest I’ll just give as any:

function transformData<T extends object>(obj: T): Flatten<T>;
function transformData(obj: any, fatherName: string | number): any
function transformData(obj: any, fatherName?: string | number): any {
  let newObj = {};
  for (let key in obj) {
    let k = obj[key];
    if (typeof k === 'object' && !Array.isArray(k) && k !== null) {
      Object.assign(newObj, transformData(k, key))
    } else {
      Object.assign(newObj, { [fatherName ? fatherName + key : key]: _isNull(k) })
    }
  }
  return newObj;
}

Let’s see how it works on this data:

const data: Data = {
  Id: 'qK1jd828Qkdlqlsz8123assaa',
  LogicalName: 'locale',
  VATRegistered: {
    Label: 'AT401',
    Value: 1000001,
    SomethingElse: {
      Hello: 123
    }
  }
}

const transformed = transformData(data);
/* const transformed: {
    Id: string;
    LogicalName: string;
    VATRegisteredLabel: string | null;
    VATRegisteredValue: number | null;
    VATRegisteredSomethingElseHello: number;
} */

console.log(transformed);
/*  {
  "Id": "qK1jd828Qkdlqlsz8123assaa",
  "LogicalName": "locale",
  "VATRegisteredLabel": "AT401",
  "VATRegisteredValue": 1000001,
  "SomethingElseHello": 123
} */

Hooray, the compiler sees that transformed is of the same type as TransformedData even though I didn’t annotate it as such. The keys are concatenated in the type as well as the object.

So, there you go. Again, I really only recommend using this for entertainment purposes, as a way of seeing how far we can push the type system. For any production use I’d probably just hardcode a call signature of the type (obj: Data) => TransformedData if that’s what you’re using it for, or maybe even stick with any and just tell people they will need to write their own types when they call it.

Playground link to code

Leave a Comment