33

Currently I'm working on a typescript project and I'm really enjoying the type inference that TypeScript brings to the table. However - when getting objects from HTTP calls - I can cast them to the desired type, get code completion and call functions on them compile time, but those result in errors runtime

Example:

class Person{
    name: string;

    public giveName() {
        return this.name;
    }

    constructor(json: any) {
        this.name = json.name;
    }
}

var somejson = { 'name' : 'John' }; // Typically from AJAX call
var john = <Person>(somejson);      // The cast

console.log(john.name);       // 'John'
console.log(john.giveName()); // 'undefined is not a function'

Although this compiles nicely - and intellisense suggests me to use the function, it gives a runtime exception. A solution for this could be:

var somejson = { 'name' : 'Ann' };
var ann = new Person(somejson);

console.log(ann.name);        // 'Ann'
console.log(ann.giveName());  // 'Ann'

But that will require me to create constructors for all my types. In paticular, when dealing with tree-like types and/or with collections coming in from the AJAX-call, one would have to loop through all the items and new-up an instance for each.

So my question: is there a more elegant way to do this? That is, cast to a type and have the prototypical functions available for it immediately?

Paleo
  • 21,831
  • 4
  • 65
  • 76
Jochen van Wylick
  • 5,303
  • 4
  • 42
  • 64
  • 1
    Type casting is purely a hint for the compiler/IDE. It will never add methods to your plain Javascript object. So you need to instantiate the JSON data as a `Person` object, there's no way around that. You could easily write a utility function to instantiate arrays/trees of objects for you. And on that note, how would casting help you with an array of objects? You'd still have to iterate over the array and cast each element, right? – Sunil D. Aug 23 '15 at 15:05

4 Answers4

10

The prototype of the class can be dynamically affected to the object:

function cast<T>(obj: any, cl: { new(...args): T }): T {
  obj.__proto__ = cl.prototype;
  return obj;
}

var john = cast(/* somejson */, Person);

See the documentation of __proto__ here.

Paleo
  • 21,831
  • 4
  • 65
  • 76
  • 1
    J'aime lire la documentation en francais ;) This solution works as long as the class doesn't have any arrow functions. – David Sherret Aug 25 '15 at 16:20
  • Ha ha! Sorry! I edited. The properties (including arrow functions) are stored in the object instance, not in the prototype. Yes, this solution will not add any properties in the object. – Paleo Aug 25 '15 at 16:43
8

Take a look at the compiled JavaScript and you will see the type assertion (casting) disappears because it's only for compiling. Right now you're telling the compiler that the somejson object is of type Person. The compiler believes you, but in this case that's not true.

So this problem is a runtime JavaScript problem.

The main goal to get this to work is to somehow tell JavaScript what the relationship between the classes are. So...

  1. Find a way to describe the relationship between classes.
  2. Create something to automatically map the json to classes based on this relationship data.

There's many ways to solve it, but I'll offer one example off the top of my head. This should help describe what needs to be done.

Say we have this class:

class Person {
    name: string;
    child: Person;

    public giveName() {
        return this.name;
    }
}

And this json data:

{ 
    name: 'John', 
    child: {
        name: 'Sarah',
        child: {
            name: 'Jacob'
        }
    }
}

To map this automatically to be instances of Person, we need to tell the JavaScript how the types are related. We can't use the TypeScript type information because we will loose that once it's compiled. One way to do this, is by having a static property on the type that describes this. For example:

class Person {
    static relationships = {
        child: Person
    };

    name: string;
    child: Person;

    public giveName() {
        return this.name;
    }
}

Then here's an example of a reusable function that handles creating the objects for us based on this relationship data:

function createInstanceFromJson<T>(objType: { new(): T; }, json: any) {
    const newObj = new objType();
    const relationships = objType["relationships"] || {};

    for (const prop in json) {
        if (json.hasOwnProperty(prop)) {
            if (newObj[prop] == null) {
                if (relationships[prop] == null) {
                    newObj[prop] = json[prop];
                }
                else {
                    newObj[prop] = createInstanceFromJson(relationships[prop], json[prop]);
                }
            }
            else {
                console.warn(`Property ${prop} not set because it already existed on the object.`);
            }
        }
    }

    return newObj;
}

Now the following code will work:

const someJson = { 
        name: 'John', 
        child: {
            name: 'Sarah',
            child: {
                name: 'Jacob'
            }
        }
    };
const person = createInstanceFromJson(Person, someJson);

console.log(person.giveName());             // John
console.log(person.child.giveName());       // Sarah
console.log(person.child.child.giveName()); // Jacob

Playground

Ideally, the best way would be to use something that actually reads the TypeScript code and creates an object that holds the relationship between classes. That way we don't need to manually maintain the relationships and worry about code changes. For example, right now refactoring code is a bit of a risk with this setup. I'm not sure that something like that exists at the moment, but it's definitely possible.

Alternative Solution

I just realized I already answered a similar question with a slightly different solution (that doesn't involve nested data though). You can read it here for some more ideas:

JSON to TypeScript class instance?

Community
  • 1
  • 1
David Sherret
  • 101,669
  • 28
  • 188
  • 178
  • It seems like there ought to be a way to skip using the `objType` argument since you're already specifying the generic in the function definition...maybe not, I'm reading about typescript generics now to see if there's a way to only have to pass in the `json` argument... – Danny Bullis Nov 13 '15 at 22:22
  • @DannyBullis the only problem with that is since it compiles to JavaScript the type information (generics included) disappear at runtime. It could be solved by analysing the typescript code itself and then generating some code from that though. It's a more complex solution. I know how to do it, but I just have to get around to writing it (probably over the holidays I will do this... I've created a reminder). – David Sherret Nov 13 '15 at 22:27
  • ahh I just realized that. I'll continue onward. Best of luck and happy holidays. – Danny Bullis Nov 13 '15 at 22:34
  • @DannyBullis thanks, you too. I will comment back here later. – David Sherret Nov 13 '15 at 22:35
  • A drawback is that Person requires an empty constructor and since Typescript allows a singe constructor this is a limitation. – Mike Argyriou Feb 06 '16 at 23:57
  • @MikeArgyriou someone could still declare multiple signatures on the constructor with one empty one to support serialization. Alternatively multiple static factory methods could be declared that fill the class with information (ex. `const person = Person.createFromName("John Doe");`) – David Sherret Feb 07 '16 at 00:17
  • This will not work with an array. If you have instead of child, children: Child[] you cannot define the relationship – Steven Yates Oct 25 '16 at 10:05
  • See here for updated version with support for array: http://pastebin.com/zzAzWVP4 – Steven Yates Oct 25 '16 at 10:13
  • What if a property is missing, or there is an additional property? This does no checking at all. – Stephan Bijzitter Dec 12 '16 at 17:11
6

You can use Object.assign, for example :

var somejson = { 'name' : 'Ann' };
var ann = Object.assign(new Person, somejson);

console.log(ann.name);        // 'Ann'
console.log(ann.giveName());  // 'Ann'

But if you have nested classes, you have to map throw the object and assign for each element.

Omar Ayoub
  • 61
  • 1
  • 2
2

The accepted answer from @DavidSherret doesn't work on TypeScript 3.5.1 onwards. See issue #31661 for the details.

Below is the updated version tested on TypeScript 4.3.2

function instanceFromJSON<T>(
  Type: { new(): T; },
  jsonObj: Record<string, unknown>,
): T {
  const obj = new Type();

  for (const prop in jsonObj) {
    if (Object.prototype.hasOwnProperty.call(jsonObj, prop)) {
      const key = prop as keyof T;

      obj[key] = jsonObj[prop] as T[keyof T];
    }
  }

  return obj;
}
WingC.
  • 21
  • 3