I'm building a batch process that includes a number of steps of varying types.
export interface IStep {
id: number;
icon: string;
name: string;
selected: boolean;
}
export class InitStep implements IStep {
id: number;
icon: string;
name: string;
selected = false;
}
export class InputStep implements IStep {
id: number;
icon: string;
name: string;
selected = false;
primaryKey: string;
file: File;
}
export class QueryStep implements IStep {
constructor () {
this.filters = [];
this.output_fields = [];
this.table_fields = [];
const filter = new Filter;
this.filters.push(filter);
}
get input_ids(): number[] {
return this.filters.map(filter => filter.input_id);
}
id: number;
icon: string;
name: string;
selected = false;
table: string;
table_fields: string[];
filters: Filter[];
output_fields: string[];
}
export class OutputStep implements IStep {
constructor() {
this.fields = [];
}
id: number;
icon: string;
name: string;
selected = false;
fields: string[];
}
export class DeliveryStep implements IStep {
constructor() {
this.output_ids = [];
}
id: number;
icon: string;
name: string;
selected = false;
output_ids: number[];
format: BatchOutputType;
frequency: BatchFrequencyType;
email: string;
password: string;
}
I want to be able to have an array of any combination/number of these steps and be able to save them to and read from localstorage.
const key = 'notgunnawork';
localStorage.setItem(key, JSON.stringify(this.steps));
const s = JSON.parse(key) as IStep[];
I knew there was a snowball's chance in hell this was going to parse correctly, obviously the parser doesn't know which steps belong to what classes ultimately. I was just wondering if there was a simple way to get my array to come out looking the same way it went in. I'll eventually be posting this list to the server and would like my .Net Core code to also be able to parse this JSON without me having to make a custom parser.
EDIT
Added the full classes of what Im trying to serialize, for more detail. The error I'm getting whenever I try to serialize and then deserialize is: "Unexpected token o in JSON at position 1"
So, I'm going to answer what I think your issue is, and if I'm wrong then feel free to ignore me 🙂
Your problem is that you have a bunch of classes with methods but when you serialize instances of these to JSON and then deserialize them back, you end up with plain-old JavaScript objects and not instances of your classes. One way to handle this is to use a custom deserializer which knows about your classes and can "hydrate" or "revive" the plain-old JavaScript objects into genuine class instances. The JSON.parse() function allows you to specify a callback parameter called reviver which can be used to do just that.
First, we need to set up a system by which the reviver will know about your serializable classes. I'm going to use a class decorator which will add each class constructor to a registry the reviver can use. We will require that a serializable class constructor be assignable to a type we can call Serializable: it needs to have a no-argument constructor and the things it constructs need to have a className property:
// a Serializable class has a no-arg constructor and an instance property
// named className
type Serializable = new () => { readonly className: string }
// store a registry of Serializable classes
const registry: Record<string, Serializable> = {};
// a decorator that adds classes to the registry
function serializable<T extends Serializable>(constructor: T) {
registry[(new constructor()).className] = constructor;
return constructor;
}
Now, when you want to deserialize some JSON, you can check if the serialized thing has a className property that's a key in the registry. If so, you use the constructor for that classname in the registry, and copy properties into it via Object.assign():
// a custom JSON parser... if the parsed value has a className property
// and is in the registry, create a new instance of the class and copy
// the properties of the value into the new instance.
const reviver = (k: string, v: any) =>
((typeof v === "object") && ("className" in v) && (v.className in registry)) ?
Object.assign(new registry[v.className](), v) : v;
// use this to deserialize JSON instead of plain JSON.parse
function deserializeJSON(json: string) {
return JSON.parse(json, reviver);
}
Okay now that we have that, let's make some classes. (I'm using your original definitions here, before your edits.) Note that we are required to add a className property and we must have a no-arg constructor (this happens for free if you don't specify a constructor, since the default constructor is no-arg):
// mark each class as serializable, which requires a className and a no-arg constructor
@serializable
class StepType1 implements IStep {
id: number = 0;
name: string = "";
prop1: string = "";
readonly className = "StepType1"
}
@serializable // error, property className is missing
class OopsNoClassName {
}
@serializable // error, no no-arg constructor
class OopsConstructorRequiresArguments {
readonly className = "OopsConstructorRequiresArguments"
constructor(arg: any) {
}
}
@serializable
class StepType2 implements IStep {
id: number = 0;
name: string = "";
prop2: string = "";
prop3: string = "";
prop4: string = "";
readonly className = "StepType2"
}
@serializable
class StepType3 implements IStep {
id: number = 0;
name: string = "";
prop5: string = "";
prop6: string = "";
readonly className = "StepType3"
}
Now let's test it out. Make some objects as you would normally do, and put them in an array:
// create some objects of our classes
const stepType1 = new StepType1();
stepType1.id = 1;
stepType1.name = "Alice";
stepType1.prop1 = "apples";
const stepType2 = new StepType2();
stepType2.id = 2;
stepType2.name = "Bob";
stepType2.prop2 = "bananas";
stepType2.prop3 = "blueberries";
stepType2.prop4 = "boysenberries";
const stepType3 = new StepType3();
stepType3.id = 3;
stepType3.name = "Carol";
stepType3.prop5 = "cherries";
stepType3.prop6 = "cantaloupes";
// make an array of IStep[]
const arr = [stepType1, stepType2, stepType3];
And let's have a function which will examine the elements of an array and check to see if they are instances of your classes:
// verify that an array of IStep[] contains class instances
function verifyArray(arr: IStep[]) {
console.log("Array contents:\n" + arr.map(a => {
const constructorName = (a instanceof StepType1) ? "StepType1" :
(a instanceof StepType2) ? "StepType2" :
(a instanceof StepType3) ? "StepType3" : "???"
return ("id=" + a.id + ", name=" + a.name + ", instanceof " + constructorName)
}).join("\n") + "\n");
}
Let's make sure it works on arr:
// before serialization, everything is fine
verifyArray(arr);
// Array contents:
// id=1, name=Alice, instanceof StepType1
// id=2, name=Bob, instanceof StepType2
// id=3, name=Carol, instanceof StepType3
Then we serialize it:
// serialize to JSON
const json = JSON.stringify(arr);
To demonstrate your original problem, let's see what happens if we just use JSON.parse() without a reviver:
// try to deserialize with just JSON.parse
const badParsedArr = JSON.parse(json) as IStep[];
// uh oh, none of the deserialized objects are actually class instances
verifyArray(badParsedArr);
// Array contents:
// id=1, name=Alice, instanceof ???
// id=2, name=Bob, instanceof ???
// id=3, name=Carol, instanceof ???
As you can see, the objects in badParsedArr do have the id and name properties (and the other class-specific instance properties like prop3 if you checked) but they are not instances of your classes.
Now we can see if the problem is fixed by using our custom deserializer:
// do the deserialization with our custom deserializer
const goodParsedArr = deserializeJSON(json) as IStep[];
// now everything is fine again
verifyArray(goodParsedArr);
// Array contents:
// id=1, name=Alice, instanceof StepType1
// id=2, name=Bob, instanceof StepType2
// id=3, name=Carol, instanceof StepType3
Yes, it works!
The above method is fine, but there are caveats. The main one: it will work if your serializable classes contain properties which are themselves serializable, as long as your object graph is a tree, where each object appears exactly once. But if you have an object graph with any kind of cycle in it (meaning that the same object appears multiple times if you traverse the graph multiple ways) then you will get unexpected results. For example:
const badArr = [stepType1, stepType1];
console.log(badArr[0] === badArr[1]); // true, same object twice
const badArrParsed = deserializeJSON(JSON.stringify(badArr));
console.log(badArrParsed[0] === baddArrParsed[1]); // false, two different objects
In the above case, the same object appears multiple times. When you serialize and deserialize the array, your new array contains two different objects with the same property values. If you need to make sure that you only deserialize any particular object exactly once, then you need a more complicated deserialize() function which keeps track of some unique property (like id) and returns existing objects instead of creating new ones.
Other caveats: this assumes your serializable classes have instance properties consisting only of other serializable classes as well as JSON-friendly values like strings, numbers, arrays, plain objects, and null. If you use other things, like Dates, you will have to deal with the fact that those serialize into strings.
Exactly how complicated serialization/deserialization is for you depends heavily on your use case.
Okay, hope that helps. Good luck!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With