Let's say we're working with a REST API that returns a JSON string representing a user. The JSON API agrees to a contract that it returns a string firstName
, a string lastName
and a number accountBalance
. We model the user as an Interface
in TypeScript as follow:
ts
interface User {
firstName: string;
lastName: string;
accountBalance: number;
}
The following code is sufficient if we always have the happy path
:
ts
const json = '{"firstName": "Kevin", "lastName": "Le", "accountBalance": 100}'
const user: User = JSON.parse(json)
But things can go wrong on the API side. The json
string returned could be:
ts
const json '{"firstName": "Kevin", "lastName": "Le", "accountBalance": "100"}'
const user: User = JSON.parse(json)
console.log(user)
The code does not blow up because accountBalance
is now being treated as an any
instead of a number
. But this "forgiving" behavior leads to other problem downstream:
ts
const balanceAfterInterest = user.accountBalance + user.accountBalance * 0.05
console.log(balanceAfterInterest) //1005
The balanceAfterInterest
is only supposed to be 105
. A better approach is to catch this problem early and handle it immediately and appropriately.
Head over to https://app.quicktype.io.
Paste the string {"firstName": "Kevin", "lastName": "Le", "accountBalance": 100}
to the left pane. Type User
as the Name
, and select JSON
as the Source type
.
In the box on the right, select TypeScript
as the Language
and make sure Verify JSON.parse results at runtime
is turned-on. Quicktype will generate the resulting code with instruction on how to use it in the middle.
Now the following code can be much safer:
ts
import { Convert, User } from "./user";
const json =
'{"firstName": "Kevin", "lastName": "Le", "accountBalance": "100"}';
try {
const user = Convert.toUser(json);
console.log(user);
} catch (e) {
console.log("Handle error", e);
}
The exception is now caught and handled early:
The generated code by quicktype for user.js
is:
// To parse this data: | |
// | |
// import { Convert, User } from "./file"; | |
// | |
// const user = Convert.toUser(json); | |
// | |
// These functions will throw an error if the JSON doesn't | |
// match the expected interface, even if the JSON is valid. | |
export interface User { | |
firstName: string; | |
lastName: string; | |
accountBalance: number; | |
} | |
// Converts JSON strings to/from your types | |
// and asserts the results of JSON.parse at runtime | |
export class Convert { | |
public static toUser(json: string): User { | |
return cast(JSON.parse(json), r("User")); | |
} | |
public static userToJson(value: User): string { | |
return JSON.stringify(uncast(value, r("User")), null, 2); | |
} | |
} | |
function invalidValue(typ: any, val: any, key: any = ''): never { | |
if (key) { | |
throw Error(`Invalid value for key "${key}". Expected type ${JSON.stringify(typ)} but got ${JSON.stringify(val)}`); | |
} | |
throw Error(`Invalid value ${JSON.stringify(val)} for type ${JSON.stringify(typ)}`, ); | |
} | |
function jsonToJSProps(typ: any): any { | |
if (typ.jsonToJS === undefined) { | |
const map: any = {}; | |
typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); | |
typ.jsonToJS = map; | |
} | |
return typ.jsonToJS; | |
} | |
function jsToJSONProps(typ: any): any { | |
if (typ.jsToJSON === undefined) { | |
const map: any = {}; | |
typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); | |
typ.jsToJSON = map; | |
} | |
return typ.jsToJSON; | |
} | |
function transform(val: any, typ: any, getProps: any, key: any = ''): any { | |
function transformPrimitive(typ: string, val: any): any { | |
if (typeof typ === typeof val) return val; | |
return invalidValue(typ, val, key); | |
} | |
function transformUnion(typs: any[], val: any): any { | |
// val must validate against one typ in typs | |
const l = typs.length; | |
for (let i = 0; i < l; i++) { | |
const typ = typs[i]; | |
try { | |
return transform(val, typ, getProps); | |
} catch (_) {} | |
} | |
return invalidValue(typs, val); | |
} | |
function transformEnum(cases: string[], val: any): any { | |
if (cases.indexOf(val) !== -1) return val; | |
return invalidValue(cases, val); | |
} | |
function transformArray(typ: any, val: any): any { | |
// val must be an array with no invalid elements | |
if (!Array.isArray(val)) return invalidValue("array", val); | |
return val.map(el => transform(el, typ, getProps)); | |
} | |
function transformDate(val: any): any { | |
if (val === null) { | |
return null; | |
} | |
const d = new Date(val); | |
if (isNaN(d.valueOf())) { | |
return invalidValue("Date", val); | |
} | |
return d; | |
} | |
function transformObject(props: { [k: string]: any }, additional: any, val: any): any { | |
if (val === null || typeof val !== "object" || Array.isArray(val)) { | |
return invalidValue("object", val); | |
} | |
const result: any = {}; | |
Object.getOwnPropertyNames(props).forEach(key => { | |
const prop = props[key]; | |
const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; | |
result[prop.key] = transform(v, prop.typ, getProps, prop.key); | |
}); | |
Object.getOwnPropertyNames(val).forEach(key => { | |
if (!Object.prototype.hasOwnProperty.call(props, key)) { | |
result[key] = transform(val[key], additional, getProps, key); | |
} | |
}); | |
return result; | |
} | |
if (typ === "any") return val; | |
if (typ === null) { | |
if (val === null) return val; | |
return invalidValue(typ, val); | |
} | |
if (typ === false) return invalidValue(typ, val); | |
while (typeof typ === "object" && typ.ref !== undefined) { | |
typ = typeMap[typ.ref]; | |
} | |
if (Array.isArray(typ)) return transformEnum(typ, val); | |
if (typeof typ === "object") { | |
return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) | |
: typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) | |
: typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) | |
: invalidValue(typ, val); | |
} | |
// Numbers can be parsed by Date but shouldn't be. | |
if (typ === Date && typeof val !== "number") return transformDate(val); | |
return transformPrimitive(typ, val); | |
} | |
function cast<T>(val: any, typ: any): T { | |
return transform(val, typ, jsonToJSProps); | |
} | |
function uncast<T>(val: T, typ: any): any { | |
return transform(val, typ, jsToJSONProps); | |
} | |
function a(typ: any) { | |
return { arrayItems: typ }; | |
} | |
function u(...typs: any[]) { | |
return { unionMembers: typs }; | |
} | |
function o(props: any[], additional: any) { | |
return { props, additional }; | |
} | |
function m(additional: any) { | |
return { props: [], additional }; | |
} | |
function r(name: string) { | |
return { ref: name }; | |
} | |
const typeMap: any = { | |
"User": o([ | |
{ json: "firstName", js: "firstName", typ: "" }, | |
{ json: "lastName", js: "lastName", typ: "" }, | |
{ json: "accountBalance", js: "accountBalance", typ: 0 }, | |
], false), | |
}; |
Top comments (2)
cool! I use ajv when validate type of any object
amour, roam, arum mora... I made these words using 5 of your function names :)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.