DEV Community

Kevin Le
Kevin Le

Posted on

25 3

Type-safely parsing JSON to a TypeScript Interface

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;
}


Enter fullscreen mode Exit fullscreen mode

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)


Enter fullscreen mode Exit fullscreen mode

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)


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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.

Alt Text

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);
}


Enter fullscreen mode Exit fullscreen mode

The exception is now caught and handled early:

Alt Text

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),
};
view raw User.ts hosted with ❤ by GitHub

Top comments (2)

Collapse
 
timo_redwit profile image
TIMO(JongHyeok Kang)

cool! I use ajv when validate type of any object

Collapse
 
insytes profile image
insytes

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.

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay