JohnWeisz / TypedJSON

Typed JSON parsing and serializing for TypeScript that preserves type information.
MIT License
603 stars 64 forks source link

How to achieve polymorphic behavior? #88

Closed furqansafdar closed 5 years ago

furqansafdar commented 5 years ago

I have a slightly complex structure where my Entity class has members property which may contains members of either Container or Action type. The $type information is also coming along in the json to identify if it is a Container or Action member with every object but how to map it to corresponding classes using this $type information?

@jsonObject
export class Entity {
    @jsonArrayMember(Member)
    public members: Member[];
}

@jsonObject
export abstract class Member {
    @jsonMember
    public id: string;

    @jsonMember
    public name: string;
}

@jsonObject
export abstract class ContainerMember extends Member {
    @jsonArrayMember(Member)
    public children: Member[];
}

@jsonObject
export class SomeConcreteContainerMember1 extends ContainerMember {
    // with additional properties
}

@jsonObject
export abstract class ActionMember extends Member {
}

@jsonObject
export class SomeConcreteActionMember1 extends ActionMember {
    // with additional properties
}
JohnWeisz commented 5 years ago

I assume this is coming from Json.NET (which uses the $type property with a fully-qualified name by default). TypedJSON is very similar, only it uses __type by default, which can be configured by using a custom type-resolver. However, one big difference is that TypedJSON does not use a fully-qualified name, but only the class-name, so you'll either need to configure Json.NET to only emit the class name, or write a type-resolver that only takes the last part of the fully qualified name.

The signature of a type-resolver is the following:

(sourceObject: Object, knownTypes: Map<string, Function>) => Function;

Where sourceObject is a raw, untyped Javascript object (you inspect this object to look for a type-hint), and knownTypes is a map of references to any additional known classes used during the deserialization process. The return value is the class reference itself. These known types must be set in advance, more on that later.

This is the default type-resolver, which uses the __type property:

(sourceObject, knownTypes) => knownTypes.get(sourceObject.__type)

You can set a custom type-resolver in the configuration:

new TypedJson(Entity, {
    typeResolver: (sourceObject, knownTypes) => knownTypes.get(sourceObject.$type)
});

If Json.NET is emitting fully-qualified names, you should be able to do this:

new TypedJson(Entity, {
    typeResolver: (sourceObject, knownTypes) =>
    {
        if (sourceObject.$type)
        {
            let typeParts = sourceObject.$type.split(".")
            let className = typeParts[typeParts.length - 1];
            return knownTypes.get(className);
        }
    }
});

Now, to recognize sub-classes during deserialization, you need to set them as known types (which is basically a listof classes). You can set this in the configuration, or in the @jsonObject decorator, this must be done on the class which you expect to contain polymorphic objects:

@jsonObject({ knownTypes: [ContainerMember, SomeConcreteContainerMember1, ... ] })
export class Entity {
    @jsonArrayMember(Member)
    public members: Member[];
}

Note: you can also specify a static method by its key for knownTypes, in that case your method should return the array of known-types.

furqansafdar commented 5 years ago

Getting error when decorating @jsonObject to my abstract classes.

Also when decorating my ContainerMember with knownTypes:

Argument of type 'typeof ContainerMember' is not assignable to parameter of type 'ParameterlessConstructor<{}>'.

@jsonObject({ knownTypes: [SomeConcreteContainerMember1, ... ] })
export abstract class ContainerMember extends Member {

    @jsonArrayMember(Member)
    public children: Member[];

}

Is there any detailed documentation available?

Neos3452 commented 5 years ago

Hey,

Thanks for submitting the question, unfortunately the documentation is not as detailed as we would wish, but I encourage you to take a look at the comments on the interfaces and at the specs we have.

The case you are mentioning can be solved immediately in two ways. The first one is the one I would recommend, because it was supported from the beginning. You can wrap the abstract class in a wrapper class that would have the knownTypes provided. Like in this test (notice the configuration on the Graph class). https://github.com/JohnWeisz/TypedJSON/blob/master/spec/polymorphism-abstract-class.spec.ts

You can also sort of hack it(mostly to go around circular dependencies). Instead of calling jsonObject on the abstract class, you call it as a normal function(after the last concrete member). To go around typescript errors, just assert the type to any. I have added a test case for that, so that you can take a look. https://github.com/JohnWeisz/TypedJSON/blob/master/spec/polymorphism-root-abstract-class.spec.ts

JohnWeisz commented 5 years ago

In addition to what @Neos3452 suggested, you should also be able to assert the jsonObject decorator itself to any:

@(jsonObject as any)({ knownTypes: [SomeConcreteContainerMember1, ... ] })
export abstract class ContainerMember extends Member {

    @jsonArrayMember(Member)
    public children: Member[];

}

This is not particularly nice or well supported due to limitations in TypeScript type checking (also, dropping jsonObject on an abstract class like this can actually make TypedJSON instantiate an abstract class if the source JSON specifies so).

krizka commented 4 years ago

Hope, it will be helpful for someone who will search, I made some util function and new decorator to achieve polymorphic behaviour, which worked quite well for me. it is added knownTypes to root type automatically, searching for root element: https://gist.github.com/krizka/c83fb1966dd57997a1fc02625719387d