8

I've the next data with the type Data

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

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

And I have to convert it to the next one:

const transformedData = {
  Id: 'qK1jd828Qkdlqlsz8123assaa',
  LogicalName: 'locale',
  VATRegisteredLabel: 'AT401',
  VATRegisteredValue: 1000001
}

I've written a function which have to transform my object and return it with the next type

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

My function:

const _isNull = (value: any) => {
  let res = value === null || undefined ? null : value;
  return res
};

function transformData<T extends {}, U extends {}>(obj: T, fatherName: keyof U | undefined) {
  let newObj;

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

  return newObj;
}

But I gain a new object with the empty object type. Is there a way to rewrite the function that it returns a new object with TransformedData type?

jcalz
  • 264,269
  • 27
  • 359
  • 360
WVFFLIFE
  • 238
  • 2
  • 7
  • Your line `let newObj;` does not create any object at all and so this will lead to a runtime error. I presume this is just a typo and I suggest you change it to `let newObj = {}` at least so that the runtime behavior is not an issue – jcalz Jan 27 '21 at 16:31

3 Answers3

13

Here's a more verbose, but more readable implementation:

// Returns R if T is a function, otherwise returns Fallback
type IsFunction<T, R, Fallback = T> = T extends (...args: any[]) => any ? R : Fallback

// Returns R if T is an object, otherwise returns Fallback
type IsObject<T, R, Fallback = T> = IsFunction<T, Fallback, (T extends object ? R : Fallback)>

// "a.b.c" => "b.c"
type Tail<S> = S extends `${string}.${infer T}`? Tail<T> : S;

// typeof Object.values(T)
type Value<T> = T[keyof T]

// {a: {b: 1, c: 2}} => {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}}
type FlattenStepOne<T> = {
  [K in keyof T as K extends string ? (IsObject<T[K], `${K}.${keyof T[K] & string}`, K>) : K]: 
    IsObject<T[K], {[key in keyof T[K]]: T[K][key]}>
};

// {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}} => {"a.b": {b: 1}, "a.c": {c: 2}}
type FlattenStepTwo<T> = {[a in keyof T]:  IsObject<T[a], Value<{[M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }>>}

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.c": {d: 1}}
type FlattenOneLevel<T> = FlattenStepTwo<FlattenStepOne<T>>

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
type Flatten<T> = T extends FlattenOneLevel<T> ? T: Flatten<FlattenOneLevel<T>>

It doesn't support all edge cases, like optional properties, but it should not be too hard to adapt it to the particular edge cases you encounter.

For example, removing the dots in the key names as in your code:

// "a.b.c" => "abc"
type RemoveDots<S> = S extends `${infer H}.${infer T}`? RemoveDots<`${H}${T}`> : S;

type FlattenWithoutDots<T> = {[K in Flatten<T> as RemoveDots<K>]: Flatten<T>[K]};
coyotte508
  • 9,175
  • 6
  • 44
  • 63
  • 3
    This is perfect! The top answer raises "Type instantiation is excessively deep and possibly infinite" for large objects, but yours works just right! – enzo Dec 16 '21 at 22:07
  • Any idea where this will hit the recursion limit? I tested it on ~4KB of JSON and hit it with that. – Richard Simões Mar 10 '23 at 22:28
11

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
-2

Try this:

function parseObject<T>(data:T, prefix:string) {
  return Object.entries(data).reduce(
    (prev, [key, value]) => ({
      ...prev,
      [`${prefix}${key}`]: value ?? null
    }),
    {}
  );
}

function parseSingle({ Id, LogicalName, VATRegistered }: Data):TransformedData {
  return <TransformedData>({ Id, LogicalName, ...parseObject(VATRegistered,'VATRegistered') })
}