1

Is it possible to clone a JSON-generated object or string into a Typescript class which I created? We are building a model of our API using Typescript classes. There’s a base class which they all extend which has common/helper methods. When we do JSON.parse(response) to auto-generate objects it creates simple objects and not our custom objects.

Is there a way we can convert those JSON-generated objects into our custom objects, so long as the field names match up? And, to make things more robust, can this but done where our custom objects’ fields are other custom objects and/or arrays of them?

Here is our code, with comments of what we’d like to achieve.

base-model.ts

export class BaseModelObject {
    uuid: string; // All of our objects in our model and JSON have this required field populated
    matchUUIDs<T extends BaseModelObject>( obj: T): boolean {
        return obj.uuid == this.uuid;
    }
}

child-model.ts

import { BaseModelObject } from 'base-model';
export class Child extends BaseModelObject {
}

parent-model.ts

import { BaseModelObject } from 'base-model';
import { Child } from 'child-model';
export class Parent extends BaseModelObject {
  children: Child[];
}

JSON payload

{
    'uuid': '0632a35c-e7dd-40a8-b5f4-f571a8359c1a',
    'children': [
        {
            'uuid': 'd738c408-4ae9-430d-a64d-ba3f085175fc'
        },
        {
            'uuid': '44d56a0d-ad2d-4e85-b5d1-da4371fc0e5f'
        }
    ]
}

In our components and directives and such, we hope to use the helper function in BaseModelObject:

Component code

let parent: Parent = JSON.parse(response);
console.log(parent.uuid); // Works!  0632a35c-e7dd-40a8-b5f4-f571a8359c1a

// Want this to print ‘true’, but instead we get TypeError: parebt.matchUUID is not a function
console.log(parent.matchUUID(‘0632a35c-e7dd-40a8-b5f4-f571a8359c1a’));

// Want this to print ‘true’, but instead we get TypeError: parent.children[0].matchUUID is not a function
console.log(parent.children[0].matchUUID(‘d738c408-4ae9-430d-a64d-ba3f085175fc’));

The problem is that JSON.parse() is not creating our classes, it’s creating simple objects with key/value pairs. So we’re thinking of “cloning” the JSON-generated object into an instance of our class, like this:

base-model.ts

export class BaseModelObject {

    [key: string]: any;
    
    matchUUIDs<T extends BaseModelObject>( obj: T): boolean {
        return obj['uuid'] == this['uuid'];
    }

    cloneFields(obj: any) {
        for (let prop in obj) {
            this[prop] = obj[prop];
        }
    }
}

Component code

let parent: Parent = new Parent(); // Creates instance of our class
parent.cloneFields(JSON.parse(response)); // Copy JSON fields to our object
console.log(parent.matchUUID('0632a35c-e7dd-40a8-b5f4-f571a8359c1a')); // prints 'true'
console.log(parent.children[0].matchUUID('d738c408-4ae9-430d-a64d-ba3f085175fc')); // Still throws TypeError: parent.children[0].matchUUID is not a function

The problem now rests in the fact that the cloning of the Parent object did not recursively clone the JSON-generated Child objects into instances of our custom Child class.

Since our Parent object is typed at compile-time and it knows that the data type of the children array is Child[] (our custom class), is there a way to use reflection to instantiate the right class?

Our logic would need to say:

  1. Create an instance of our custom class
  2. Tell our instance to clone the fields from the JSON-generated object
  3. Iterate over the fields in the JSON-generated object
  4. For each field name from the JSON-generated object, find the "type definition" in our custom class
  5. If the type definition is not a primitive or native Typescript type, then instantiate a new instance of that "type" and then clone it's fields.

(and it would need to recursively traverse the whole JSON object structure to match up all other custom classes/objects we add to our model).

So something like:

cloneFields(obj: any) {
    for (let prop in obj) {
        let A: any = ...find the data type of prop...
        if(...A is a primitive type ...) {
            this[prop] = obj[prop];
        } else {
          // Yes, I know this code won't compile.
          // Just trying to illustrate how to instantiate
          let B: <T extends BaseModelUtil> = ...instantiate an instance of A...
          B.cloneFields(prop);
          A[prop] = B;
        }
    }
}

Is it possible to reflect a data type from a class variable definition and then instantiate it at runtime?

Or if I'm going down an ugly rabbit hole to which you know a different solution, I'd love to hear it. We simply want to build our custom objects from a JSON payload without needing to hand-code the same patterns over and over since we expect our model to grow into dozens of objects and hundreds of fields.

Thanks in advance! Michael

Michael C
  • 53
  • 6

1 Answers1

1

There are several ways to do that, but some requires more work and maintenance than others.

1. Simple, a lot of work

Make your cloneFields abstract and implement it in each class.

export abstract class BaseModelObject {
    uuid: string;
    matchUUIDs<T extends BaseModelObject>( obj: T): boolean {
        return obj.uuid == this.uuid;
    }

    abstract cloneFields(obj: any);
}

class Parent extends BaseModelObject {
    children: Child[];

    cloneFields(obj: any) {
        this.children = obj.children?.map(child => { 
            const c = new Children();
            c.cloneFields(child);
            return c;
        });
    }
}

2. Simple, hacky way

If there is no polymorphism like:

class Parent extends BaseModelObject {
    children: Child[] = [ new Child(), new ChildOfChild(), new SomeOtherChild() ]
}

Property names mapped to types.

const Map = {
    children: Child,
    parent: Parent,
    default: BaseModelObject 
}

export class BaseModelObject {
    uuid: string;
    matchUUIDs<T extends BaseModelObject>( obj: T): boolean {
        return obj.uuid == this.uuid;
    }

    cloneFields(obj: any) {
        for (const prop in obj) {
            if (obj.hasOwnProperty(prop)) {
                this[prop] = obj[prop]?.map(child => { // You have to check it is an array or not,..
                    const c = new (Map[prop])();
                    c.cloneFields(child);
                    return c;
                });
            }
        }
    }
}

You can serialize hints into that JSON. Eg. property type with source/target type name and use it to resolve right types.

3. Reflection

Try tst-reflect. It is pretty advanced Reflection system for TypeScript (using custom typescript transformer plugin).

I'm not going to write example, it would be too complex and it depends on your needs.

You can use tst-reflect to list type's properties and get their types. So you'll be able to validace parsed data too.

Just some showcase from its README:

import { getType } from "tst-reflect";

function printTypeProperties<TType>() 
{
    const type = getType<TType>(); // <<== get type of generic TType ;)
    
    console.log(type.getProperties().map(prop => prop.name + ": " + prop.type.name).join("\n"));
}

interface SomeType {
    foo: string;
    bar: number;
    baz: Date;
}

printTypeProperties<SomeType>();

// or direct
getType<SomeType>().getProperties();

EDIT:

I created a package ng-custom-transformers that simplifies this a lot. Follow its README.

DEMO

EDIT old:

Usage with Angular

Angular has no direct support of custom transformers/plugins. There is a feature request in the Angular Github repo.

But there is a workaround.

You have to add ngx-build-plus. Run ng add ngx-build-plus. That package defines "plugins".

Plugins allow you to provide some custom code that modifies your webpack configuration.

So you can create plugin and extend Angular's webpack configuration. But here comes the sun problem. There is no public way to add the transformer. There were AngularCompilerPlugin in webpack configuration (@ngtools/webpack) which had private _transformers property. It was possible to add a transformer into that array property. But AngularCompilerPlugin has been replaced by AngularWebpackPlugin which has no such property. But is is possible to override method of AngularWebpackPlugin and add a transformer there. Getting an instance of the AngularWebpackPlugin is possible thanks to ngx-build-plus's plugins.

Code of the plugin

const {AngularWebpackPlugin} = require("@ngtools/webpack");
const tstReflectTransform = require("tst-reflect-transformer").default;

module.exports.default = {
  pre() {},
  post() {},

  config(cfg) {
    // Find the AngularWebpackPlugin in the webpack configuration; angular > 12
    const angularWebpackPlugin = cfg.plugins.find((plugin) => plugin instanceof AngularWebpackPlugin);

    if (!angularWebpackPlugin) {
      console.error("Could not inject the typescript transformer: AngularWebpackPlugin not found");
      return;
    }

    addTransformerToAngularWebpackPlugin(angularWebpackPlugin, transformer);

    return cfg;
  },
};

function transformer(builderProgram) {
  return tstReflectTransform(builderProgram.getProgram());
}

function addTransformerToAngularWebpackPlugin(plugin, transformer) {
  const originalCreateFileEmitter = plugin.createFileEmitter; // private method

  plugin.createFileEmitter = function (programBuilder, transformers, getExtraDependencies, onAfterEmit, ...rest) {
    if (!transformers) {
      transformers = {};
    }

    if (!transformers.before) {
      transformers = {before: []};
    }

    transformers.before = [transformer(programBuilder), ...transformers.before];

    return originalCreateFileEmitter.apply(plugin, [programBuilder, transformers, getExtraDependencies, onAfterEmit, ...rest]);
  };
}

Then it is required to execute ng commands (such as serve or build) with --plugin path/to/the/plugin.js.

I've made working StackBlitz demo.

Resources I've used while preparing the Angular demo:

Hookyns
  • 159
  • 1
  • 4
  • I spent a good 8 hours trying to get that library to work, but I keep getting `[ERR] tst-reflect: You call getType() method directly` error. I have followed instructions at [link](https://github.com/Hookyns/tst-reflect#how-to-start), but still can't get this to work. – Michael C Feb 16 '22 at 18:31
  • I spent another 8 hours today and, whenever I set a debug breakpoint (on my browser) after getting the Type, it looks empty (`_name=undefined, _properties=[]`). And `Type.getTypes()` still returns an empty array. Do you have any better documentation for setting up and running `tst-reflect` in an Angular app that runs in a browser? Thank you. – Michael C Feb 17 '22 at 04:44
  • Well I'm not sure how `ng` handles typescript transformers because webpack is quite encapsulated there. Have you updated webpack configuration somehow? BTW, be sure you use the latest versions of tst-reflect. Message `You call getType() method directly` seems obsolete. – Hookyns Feb 17 '22 at 07:46
  • I have no experience with using custom transformers with Angular. I've just googled this package `https://www.npmjs.com/package/@ngtools/webpack` which semms like requirement to use custom transformers. I've found this article: https://indepth.dev/posts/1045/having-fun-with-angular-and-typescript-transformers I'll try to make some REPL with Angular. – Hookyns Feb 17 '22 at 07:57
  • Okay, I've found the way how to do it, but there are still some issues in transpilled code. And it is not official. Angular do not support custom transformers yet. I've made this StackBlitz: https://stackblitz.com/edit/tst-reflect-angular?file=reflectPlugin.js (that NG app is not finished yet, just ignore that). You have to install `tst-reflect`, `tst-reflect-transformer` then `ng add ngx-build-plus`. Run with --plugin param `ng serve --plugin ~/projects/tst-reflect-angular/reflectPlugin.js`. `reflectPlugin.js` is script overriding some internal method to add the transformer. – Hookyns Feb 17 '22 at 19:46
  • I've extended the answer by more details about Angular usage of the reflection package. – Hookyns Feb 18 '22 at 11:03
  • Thank you for taking the initiative to prototype that. I really appreciate it. I'm going to look at it again in the next day or 2 and I'll let you know what I find. – Michael C Feb 21 '22 at 14:04
  • But there is still issue with that. Reflection does not work after some hot-reloads, so full build is required when it happen. Some parts of code are missing after hot-reload so there are errors. It can be problem with Angular or with `tst-reflect`. I think there are two things in tst-reflect which can potentially cause this. I'm gonna test it, but it'll take some time. – Hookyns Feb 22 '22 at 07:09
  • I get `[ERR] tst-reflect-transformer Unable to resolve type's symbol. Returning type Unknown` at compile time. I'm using `ngx-build-plus`, have updated builder in `angular.json`, and am running with `--plugin` argument (diff error if I omit that). Are there any debug flags I can enable to help trace why this custom transformer still fails? – Michael C Mar 02 '22 at 00:10
  • You can enable traces by enabling "debugMode". Check https://github.com/Hookyns/tst-reflect/wiki/Configuration Feel free to create issue in the tst-reflect's repository, because this is not a problem inside your project anymore. If you are able to create a reproducible example, it would be nice. Eg. fork that StackBlitz demo https://stackblitz.com/edit/tst-reflect-angular-ag-custom-transformers – Hookyns Mar 04 '22 at 10:16
  • I see you have links to 2 StackBlitz demos in your recent updates. They seem mutually-exclusive (since they use different `builder` values in `angular.json`. What is the difference between the 2 demos and under which conditions would I use each? My Angular app is for a browser, so should I use the "ag-custom-transformers"? – Michael C Mar 22 '22 at 23:05