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

1 Answer 1

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:

Sign up to request clarification or add additional context in comments.

11 Comments

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, but still can't get this to work.
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.
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.
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: indepth.dev/posts/1045/… I'll try to make some REPL with Angular.
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: 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.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.