Skip to content

Instantly share code, notes, and snippets.

@LexVocoder
Last active April 13, 2022 02:06
Show Gist options
  • Save LexVocoder/15f96f8598d0f1799932a6465e53a1dc to your computer and use it in GitHub Desktop.
Save LexVocoder/15f96f8598d0f1799932a6465e53a1dc to your computer and use it in GitHub Desktop.
Helpers for converting JSON to actual TypeScript classes
/***
Inspired by http://choly.ca/post/typescript-json/ , except this is only for parsing (not stringifying).
If you use this, I'd appreciate some credit and some feedback ... but neither are required.
Copyright license (Apache) is at the bottom.
NOTE THIS WILL PROBABLY NOT WORK if your uglifier changes constructor/class names. This is a pretty common thing, and it makes me sad.
The main problem this solves is that JSON.stringify returns plain old JS objects, not TS instances.
Even after casting, method calls don't work:
(<Foo>JSON.parse(str)).someMethodDefinedOnFoo(); // throws TypeError
The secondary problem is type safety; the JSON may produce a number when you really want a string.
TypeScript will neither detect this situation nor auto-convert for you.
Example of how to use this:
class Bar {
a: Array<string>;
static fromJSON = makeJSONParser(Bar.prototype, {
a: makeArrayConverter(String),
});
}
class Foo {
created: Date;
n: number;
s: string;
primary: Bar;
stringsToBars: {[key: string]: Bar};
stringsToNumbers: {[key: string]: number};
someMethodDefinedOnFoo() { return 'I exist!'; }
static fromJSON = makeJSONParser(Foo.prototype, {
created: x => new Date(x),
n: Number,
s: String,
primary: Bar.fromJSON,
stringsToBars: makeDictionaryConverter(Bar.fromJSON),
stringsToNumbers: makeDictionaryConverter(Number),
});
}
Instead of JSON.parse, use Foo.fromJSON and Bar.fromJSON.
(Foo.fromJSON(str)).someMethodDefinedOnFoo(); // works!
***/
// `prototype` specifies the class to create.
// `overrides` maps fields in the class to converters. Each converter takes `any` and returns the type for that field.
// Returns a function that takes either a string or a Plain Old JavaScript Object, which returns an instance of the class (prototype).
//
function makeJSONParser<T extends object, K extends keyof T>(prototype: T, overrides: {[_:string]: (any) => T[K]}): (jsonOrObject: string|object) => T {
console.log(`makeJSONParser: Making a parser for ${getType(prototype)}`);
return (jsonOrObject: string|object) => {
console.log(`makeJSONParser_inner: Parsing a ${getType(prototype)}`);
var rawInstance: object =
((typeof(jsonOrObject) === 'string') ?
JSON.parse(jsonOrObject) :
jsonOrObject);
var newbie = Object.create(prototype);
var customConversions: T = <T>{};
Object.entries(overrides).forEach((pair: Array<any>) =>
{
const fieldName: K = pair[0];
const convert: (any) => T[K] = pair[1];
customConversions[fieldName] = convert(rawInstance[<any>fieldName]);
});
const res = Object.assign(newbie, rawInstance, customConversions);
console.log(`makeJSONParser_inner: Returning ${toPrintable(res)}`);
return res;
};
}
function makeArrayConverter<T>(elementConverter: (any) => T) {
return function(x: Array<any>) {
const res = x.map(elt => elementConverter(elt));
if (res.length > 0) {
console.log(`makeArrayConverter_inner: I made an Array<${(res[0]).constructor.name}>`);
}
return res;
};
}
//
function makeDictionaryConverter<V>(valConv: (rawVal: any) => V)
{
return (x: any) => {
var res: {[key: string]: V} = {};
Object.entries(x).forEach(pair => {
const rawKey = pair[0];
const rawVal = pair[1];
const refinedKey = String(rawKey);
const refinedVal = valConv(rawVal);
res[refinedKey] = refinedVal;
});
console.log(`makeDictionaryConverter_inner: I made ${toPrintable(res)}`);
return res;
};
}
function makeJSONParser<T extends object, K extends keyof T>(prototype: T, overrides: {[_:string]: (any) => T[K]}): (jsonOrObject: string|object) => T {
console.log(`makeJSONParser: Making a parser for ${getType(prototype)}`);
return (jsonOrObject: string|object) => {
console.log(`makeJSONParser_inner: Parsing a ${getType(prototype)}`);
var rawInstance: object =
((typeof(jsonOrObject) === 'string') ?
JSON.parse(jsonOrObject) :
jsonOrObject);
var newbie = Object.create(prototype);
var customConversions: T = <T>{};
Object.entries(overrides).forEach((pair: Array<any>) =>
{
const fieldName: K = pair[0];
const convert: (any) => T[K] = pair[1];
customConversions[fieldName] = convert(rawInstance[<any>fieldName]);
});
const res = Object.assign(newbie, rawInstance, customConversions);
console.log(`makeJSONParser_inner: Returning ${toPrintable(res)}`);
return res;
};
}
function makeArrayConverter<T>(elementConverter: (any) => T) {
return function(x: Array<any>) {
const res = x.map(elt => elementConverter(elt));
if (res.length > 0) {
console.log(`makeArrayConverter_inner: I made an Array<${(res[0]).constructor.name}>`);
}
return res;
};
}
//
function makeDictionaryConverter<V>(valConv: (rawVal: any) => V)
{
return (x: any) => {
var res: {[key: string]: V} = {};
Object.entries(x).forEach(pair => {
const rawKey = pair[0];
const rawVal = pair[1];
const refinedKey = String(rawKey);
const refinedVal = valConv(rawVal);
res[refinedKey] = refinedVal;
});
console.log(`makeDictionaryConverter_inner: I made ${toPrintable(res)}`);
return res;
};
}
/***
Copyright 2018 Alex Hankins
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
***/
/***
Parses JSON to classes cleanly, but ONLY for fields that have default values.
This might not work so great if you use an uglifier that changes constructor/class names.
Continuing the example above, you would need to define Foo and Bar like this:
class Bar {
a: Array<string> = [];
someMethodDefinedOnBar() { return "I'm a real Bar."; }
}
class Foo {
created = new Date();
n: number = 0;
s: string = "";
primary = new Bar(); // It's really important to use ctor here.
stringsToBars: { [key: string]: Bar } = {};
stringsToNumbers: { [key: string]: number } = {};
someMethodDefinedOnFoo() { return `I'm a real Foo!`; }
// If you don't want to specify defaults, you can similarly use a static field, named `exemplarCompletion`, like this:
static exemplarCompletion = {
stringsToBars: { "": new Bar() },
stringsToNumbers: { "": 0 },
};
}
Now, you can deserialize like this:
const parser = new JSONParser();
const jsonString = `{
"created": "2018-01-01T00:01:02Z",
"nz": 42,
"s": 43,
"primary": {
"a": ["cheese", "bean"]
},
"stringsToBars": {"stbKey": { "a": ["loop"]}},
"stringsToNumbers": {"x": 3}
}`;
const foo = parser.newFromJSON(Foo, jsonString);
foo.someMethodDefinedOnFoo(); // works!
***/
// returns true if x is null!
export function defined(x) {
return (typeof (x) !== 'undefined');
}
export function repr(val: any) {
const ctorName = (val && val.constructor && val.constructor.name);
const typeName = typeof (val);
var res = undefined;
if (ctorName === 'String' || ctorName === 'Boolean' ||
typeName === 'number' || typeName === 'string' || typeName === 'undefined' ||
val === null) {
res = JSON.stringify(val);
} else if (ctorName === 'Array') {
res = `[` +
val.map(repr).join(',') +
']';
} else if (ctorName === 'Date') {
res = `new Date(${JSON.stringify(val)})`;
} else if (ctorName === 'Object') {
res = `{` +
Object.entries(val).map(([k, v]) => repr(k) + ": " + repr(v))
.join(', ') +
'}';
} else if (ctorName) {
res = `new ${ctorName}(` +
Object.entries(val).map(([k, v]) => k + " = " + repr(v))
.join(', ') +
')';
} else {
res = `/* anon type: */ ${JSON.stringify(val)}`;
}
//console.log(`repr(${JSON.stringify(val)}) --> '${res}'`);
return res;
}
export class JSONParser {
public constructor(
private warnIfInputHasExtraFields: boolean,
private logger: any = console,
) { }
public newFromJSON<T>(ctor: new () => T, json: string): T {
return this.newFromPOJO(ctor, JSON.parse(json));
}
public newFromPOJO<T>(ctor: new () => T, pojo: object, path = ''): T {
const description = "class named " + ctor.name;
//console.log(`Converting ${description} ...`);
var res = new ctor();
const exemplar = new ctor();
const completion = ctor['exemplarCompletion'];
if (completion) {
Object.assign(exemplar, completion);
//console.log(`Complete exemplar for ${description} = ${repr(exemplar)}`);
}
Object.keys(exemplar)
.forEach(fieldName => {
const typeExemplar = exemplar[fieldName];
const val = pojo[fieldName];
//console.log(`Setting field ${ctor.name}.${fieldName} ...`)
res[fieldName] = this.convert(pojo[fieldName], typeExemplar, `${path}(${ctor.name}).${fieldName}`);
});
if (this.warnIfInputHasExtraFields) {
const extraFields = Object.keys(pojo).filter(k => !defined(res[k]));
if (extraFields.length > 0) {
this.logger.warn(`${JSONParser.name}.${this.newFromPOJO.name}:
At ${this.printablePath(path)}, found extra fields (${repr(extraFields)}) when parsing ${repr(pojo)}.`);
}
}
console.log(`Returning ${repr(res)} for ${this.printablePath(path)}`);
return res;
}
private readonly convertersByCtorName = {
'Date': val => new Date(val),
};
private readonly convertersByType = {
'string': String,
'number': Number,
'boolean': Boolean,
};
private convert(val: any, exemplar: any, path: string): any {
//console.log(`Converting ${repr(val)} to something like ${repr(exemplar)} ...`)
const exType = typeof(exemplar);
if (exType === 'undefined') {
throw new Error(`No exemplar available at ${this.printablePath(path)}`);
}
if (!defined(val) || val === null) {
return val;
}
var simpleFunction: (v: any) => any = this.convertersByType[exType];
if (simpleFunction) {
//console.log(`Found simple converter for ${typeof(exemplar)}!`)
const res = simpleFunction(val);
return res;
}
const ctor = <new () => any>((<object>exemplar).constructor);
const compoundFunction: (v: any) => any = this.convertersByCtorName[ctor.name];
if (compoundFunction) {
//console.log(`Found compound converter for ${ctor.name}!`)
const res = compoundFunction(val);
//console.log(`convert(${repr(val)}, ${repr(exemplar)}) --> ${repr(res)}`);
return res;
}
if (ctor.name === 'Array') {
return this.convertArray(val, exemplar, path);
}
if (ctor.name === 'Object') {
return this.convertDictionary(val, exemplar, path);
}
// It's a class.
const res = this.newFromPOJO(ctor, val, path);
//console.log(`convert(${repr(val)}, ${repr(exemplar)}) --> ${repr(res)}`);
return res;
}
private convertArray<T>(val: Array<T>, exemplar: Array<T>, path: string) {
//console.log("Converting array ...");
if (exemplar.length >= 2) {
throw new Error(`At ${this.printablePath(path)}, exemplar for a dictionary must have fewer than two elements, but got ${repr(exemplar)}`);
}
var res;
if (exemplar.length > 0) {
res = val.map((x, index) => <T>(this.convert(x, exemplar[0], `${path}[${index}]`)));
} else {
res = val;
}
console.log(`Returning ${repr(res)} for ${this.printablePath(path)}.`);
return res;
}
private convertDictionary(jsObj: object, exemplar: object, path: string): object {
const description = "dictionary";
//console.log(`Converting ${description} ...`);
var res = {};
var keyExemplar;
var valExemplar;
// exemplar has {k:v} and no other fields!!!
Object.entries(exemplar).forEach(([k, v]) => {
if (defined(keyExemplar) || defined(valExemplar)) {
throw new Error(`At path ${this.printablePath(path)}, exemplar for a dictionary must have only one key and one value, but got ${repr(exemplar)}`);
}
keyExemplar = k;
valExemplar = v;
});
if (defined(keyExemplar) || defined(valExemplar)) {
Object.entries(jsObj).forEach(([k, v], index) => {
const resKey = this.convert(k, keyExemplar, `${path}[${index}]`);
console.log(`Setting key ${resKey} ...`);
res[resKey] = this.convert(v, valExemplar, `${path}[${repr(resKey)}]`);
});
} else {
res = jsObj;
}
console.log(`Returning ${repr(res)} for ${this.printablePath(path)}.`)
return res;
}
private printablePath(path: string) {
if (path === '') {
return 'path {.}';
} else {
return `path {${path}}`;
}
}
}
@MikeMitterer
Copy link

Hi + Thanks! I tried your approach and looks promising but I got several linter problems... You are using any for example as param name in callback prototype. console is not allowed, getType and toPrintable is not defined. Is there a reason for using var instead of let/const? makeJSONParser and makeArrayConverter is defined twice

@LexVocoder
Copy link
Author

LexVocoder commented Jan 31, 2019

Oh! I didn't see you there. Sorry. Yes, the lint is probably awful. I need to clean this up! Thanks!

The "any as a parameter name" is probably a geniune goof. As for console, you can remove the calls. They're just for debugging. getType and toPrintable are defined in other gists. Further, getType and anything relying on constructor.name are [usually] unreliable in code that has been uglified. Oh, yeah, var is a bad habit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment