How to send wrapped generic SObjects from Lightning to Apex

I know that when you serialize an SObject it has some extra information on it that you might not expect:

Contact c = new Contact();
system.debug(json.serialize(c));

Result:

{"attributes":{"type":"Contact"}}

I think the error message is simply telling you that you need to include this in your JSON that you send back to the controller so that it knows what type of SObject you're attempting to represent in your JSON.

Even if you attempt to do the same with an SObject:

Contact c = new Contact();
system.debug(json.serialize((SObject)c));

The output is the same:

{"attributes":{"type":"Contact"}}

Looking at the documentation in apex you cannot construct a new SObject. According to this documentation the only way to "construct" a generic SObject is using the newSObject method. The documentation for this method is here which shows that even with the newSObject method you must call it from a specific SObject type such as account.

To me this says that you never actually work with a generic SObject. You can create a variable that is capable of storing any arbitrary SObject type but the actual instances of the SObjects all contain an attribute defining what type of SObject it is.

What to do

Add in the attributes property and set the type in your script before passing it. This will allow you to store it in a generic SObject variable.

It doesn't matter what type of object you're working with it should still have access to it's own attributes property in your JS. You just need to make sure you grab it from the object and include it in your request.

Alternatively

In your controller use JSON.deserializeUntyped() to bring your JSON into a Map<String, Object>.

This will allow you to work on it as if it were a generic object, not necessarily a generic SObject.

In your case it might look like (execute anonymous copy pasta):

public class WrappedRecord {
    @AuraEnabled
    public Map<String, Object> genericRecord;

    public WrappedRecord(SObject genericRecord) {
        this.genericRecord = (Map<String, Object>) JSON.deserializeUntyped(JSON.serialize(genericRecord));
    }

    public SObject getGenericRecord() {
        return (SObject) JSON.deserialize(JSON.serialize(genericRecord), SObject.class);
    }
}

WrappedRecord test = new WrappedRecord([SELECT Id FROM Account LIMIT 1]);
system.debug(test.genericRecord.get('Id'));

Map<String, Object> attributes = (Map<String, Object>) test.genericRecord.get('attributes');
system.debug(attributes.get('type'));

update test.getGenericRecord();

You can use this answer for a little more info on working with untyped JSON.

Functionally this will be extremely similar. If you were accessing the ID of an SObject you would use SObject.get('Id'); If you were to use the untyped method, since it's a map, accessing an Id field would look like map.get('Id');.

Extra

Contact c  = new Contact();
String s = JSON.serialize(c);
SObject so = (SObject) JSON.deserialize(s, SObject.class);
system.debug(JSON.serialize(so));

This also returns the same JSON output.

String s = '{"Id":"006C000001AKiyV"}';
SObject so = (SObject) JSON.deserialize(s, SObject.class);
system.debug(JSON.serialize(so));

This returns the exact same error you reported.

Contact c  = new Contact();
String s = '{"attributes":{"type":"SObject"}}';
SObject so = (SObject) JSON.deserialize(s, SObject.class);
system.debug(JSON.serialize(so));

This returns the following error:

Error


You would run into the same issue even if you used a normal SObject instead of a wrapped one. The problem is that because you're sending it as a String, in order to deserialize it, it wants an attributes property with type of the record type name.

I ran into this problem recently and added the following javascript utility function:

addTypeAttribute: function(sObjects, type) {
    return sObjects.map(sObject => {
        sObject.attributes = { type };
        return sObject;
    });
},

Just pass your records and your type as a string ie. Account.


Great question and findings! This question really helped to learn and explore quite a few new things today.

With some experimenting around this, here's some additional details and thoughts around this.

1. SObject when serialized itself always consists of additional field attributes. This field consists of values type and a url as observed in the logs (I can only find this reference in this documentation's example codes).

{"attributes":{...},"Name":"Acme","Id":"001D000000Jsm0WIAR"}}

However, when its sent back as WrappedSObject, its the content of the object which gets serialized and is returned back to the client as below:

{"record":{"Name":"Test","Id":"001...AAQ"}}

The issue here seems to be at the lightning platform level that it does not identify that the record attribute itself is an SObject and that it needs to add the necessary attributes field in there.

2. Now when you send the value back to apex, during deserialization of WrappedSObject, JSON.deserialize(..) identifies the record as an SObject and not finding the required attribtues field in there results in the failure with the error message being experienced.


While you can not prefer to use SObject altogether here, but if you want to have the data transfer using SObject only, then consider using it directly without nesting it in any custom class and without in fact the need of serializing or deserializing it. So your code will look something as below.

Apex Aura Controller

@AuraEnabled
public static SObject getMySObject() {
    return [SELECT Name FROM Account LIMIT 1];
}

@AuraEnabled
public static void saveSObject(SObject sObjectVal) {
    system.debug('SObject Type:' + sObjectJson.getSObjectType());
    system.debug('Name:' + sObjectJson.get('Name'));
}

JS Controller

action.setParams({
    sObjectVal:  cmp.get("v.mySObject")
})