DEV Community

Cover image for Recursively Clone Objects without sacrificing Type Safety
Andrew Ross
Andrew Ross

Posted on

Recursively Clone Objects without sacrificing Type Safety

What not to do

When writing utilities to recursively clone objects that are passed through to a helper as transient args, you don't need to call a given top level function from within some deeply nested corner of that same function from within an if conditional that's within an if conditional nested within a for loop within a for loop...and so on. That dizzying approach becomes rapidly convoluted usually by the time you're approximately several nested layers of conditionals and/or for-loops deep.

Instead, utilize TypeScript and its generics and break it up into bite-sized pieces which have the added benefit of being reusable throughout your code base for type checking and more.

(1/4) Writing the Base Depth type

This recursively mapped generic type traverses any given object and its key-value pairs using static types alone; it also poises us for straightforward type-inference in the following step

export type Depth<
  Y extends { [record: string | symbol | number]: unknown },
  X extends keyof Y = keyof Y
> = {
  [H in keyof Y[X]]: Y[X][H][keyof Y[X][H]];
};
Enter fullscreen mode Exit fullscreen mode

(2/4) Writing the InferDepth generic type

The following generic gracefully "unwraps" Depth by inferring each of the two generic types passed through.

export type InferDepth<T> = T extends Depth<infer U, infer X> ? U[X] : T;
Enter fullscreen mode Exit fullscreen mode

The one-liner above may not look like it's doing much, but on ctrl+hover, TS Intellisense informs us that InferDepth is of type

type InferDepth<T> = T extends Depth<infer U extends {
    [record: string]: unknown;
    [record: number]: unknown;
    [record: symbol]: unknown;
}, infer X extends keyof infer U extends {
    [record: string]: unknown;
    [record: number]: unknown;
    [record: symbol]: unknown;
}> ? U[X] : T
Enter fullscreen mode Exit fullscreen mode

All of that from a one liner that "unwraps" Depth by utilizing type inference

(3/4) Defining the inferObj and objInference tandem

Things are getting functional now, ja?

export const inferObj = <J, T extends InferDepth<J>>(props: T) => props;

export const objInference = <T extends Parameters<typeof inferObj>["0"]>(
  props: T
) => inferObj(props);
Enter fullscreen mode Exit fullscreen mode

Skeptical? That's okay, I was too while writing this code out for the first time late one night from scratch. But it works wonderfully, let's take a look at how with an example.

(4/4) To be non-readonly, or not to be

The following ugly object I slapped together contains one particularly bodacious nesting occurrence for the purpose of showcasing objInference's utility

const devObject = {
  theSimpleKey: "a simple string",
  getDate: (rr: InstanceType<typeof Date>) => new Date(rr.toISOString()),
  nestttt: () => [
    {
      "let's": {
        keep: {
          nesting: true,
          deeper: [
            {
              because: {
                why: {
                  the: {
                    fuck: { not: () => (Date.now() % 2 === 0 ? true : false) }
                  }
                }
              }
            }
          ]
        }
      }
    }
  ]
};
Enter fullscreen mode Exit fullscreen mode

The deeply nested function returns a conditional within an array of objects that's also nested within an array of objects. We can expect the sample code to return true 500 times each second and false 500 times each second, but that's beside the point.

the point is, our objInference function helper seamlessly handles the recursive cloning of this non-readonly object in a one liner -- and it doesn't sacrifice type-safety while also avoiding unnecessary complexity

const intelliCheck = () => objInference(devObj);
Enter fullscreen mode Exit fullscreen mode

which has the following type inferred instantly

declare const intelliCheck: () => {
    theSimpleKey: string;
    getDate: (rr: InstanceType<typeof Date>) => Date;
    nestttt: () => {
        "let's": {
            keep: {
                nesting: boolean;
                deeper: {
                    because: {
                        why: {
                            the: {
                                fuck: {
                                    not: () => boolean;
                                };
                            };
                        };
                    };
                }[];
            };
        };
    }[];
};
Enter fullscreen mode Exit fullscreen mode

Appending an as const on the end of any given objects definition only heightens the type inference precision of these recursively cloning object rippers

const devObject = {
  theSimpleKey: "a simple string",
  getDate: (rr: InstanceType<typeof Date>) => new Date(rr.toISOString()),
  nestttt: () => [
    {
      "let's": {
        keep: {
          nesting: true,
          deeper: [
            {
              because: {
                why: {
                  the: {
                    fuck: { not: () => (Date.now() % 2 === 0 ? true : false) }
                  }
                }
              }
            }
          ]
        }
      }
    }
  ]
} as const; // now it's readonly 
Enter fullscreen mode Exit fullscreen mode

Wrapping it up

To provide an insight into how this objInference recursively cloning helper is actually ripping the internals of any given object passed through it, let's take a look at the following tamer object

const numObj = {
  one: 1,
  two: 2,
  three: "3",
  four: "IV"
} as const;

objInference(numObj)
Enter fullscreen mode Exit fullscreen mode

while hovering and holding the "ctrl" key over the objInference cloner wrapping the numObj constant above, its internal workings become evident

const objInference: <{
    readonly one: 1;
    readonly two: 2;
    readonly three: "3";
    readonly four: "IV";
}>(props: {
    readonly one: 1;
    readonly two: 2;
    readonly three: "3";
    readonly four: "IV";
}) => {
    readonly one: 1;
    readonly two: 2;
    readonly three: "3";
    readonly four: "IV";
}
Enter fullscreen mode Exit fullscreen mode

Here's a link to the typescript playground containing pieces of the code used throughout this late-night post; it's worth checking out, especially if you're still apprehensive about this type-first approach to recursively cloning objects.

Cheers

Boop

Top comments (0)