Fully Type-Safe JSON in TypeScript

json typescript

There are several libraries available for validating JSON in TypeScript, but the ones that I am aware of have at least one of the following drawbacks.

  1. The type safety of TypeScript itself does not come into play until after the validation is complete.
  2. There is excessive boilerplate code, either manually created or generated.
  3. The flexibility for including arbitrary constraints as part of the validation is limited.

The libraries suffering from issue 1 do all validation outside of the type safety of the language.  Then at the very end of the validation, a leap of faith is made, and a completely untyped object is forced as a whole into TypeScript with as SomeType.  This is error prone because there is nothing to verify that the logic used in the validation really matches the type definition of the TypeScript interface that is being matched against.  Alternatively, we can approach this using the type safety of the language as the engine of our validation.  By combining this with a functional programming mindset, we can achieve a result that is both maximally type-safe and has minimal boilerplate.  We can also avoid code generation, which can present its own set of problems.

In this article we will walk through the development of such a package.

The Simple Case of Flat JSON

As a starting point, let’s consider a single-level interface using only JSON’s primitive types.

interface Person {
  id   : number
  name : string
  free : boolean
}

For this, we need functions to validate that each of these attributes in a given JSON object match the type in this interface.  For id, the function below would do what we need.

function validateNumber(value:unknown) : number {
    if (typeof value === "number") {
        return value as number
    }
    throw Error("Invalid number value.")
}

We also want the ability to apply arbitrary constraints to individual values, so let’s add an additional parameter for a constraint function.

type ConstraintNumber = (x:number) => boolean
function validateNumber(value:unknown, constraint?:ConstraintNumber) : number {
    if (typeof value === "number") {
        const v = value as number
        if (constraint === undefined || constraint(v)) {
            return v
        }
    }
    throw Error("Invalid number or failed constraint.")
}

This same function can handle all three JSON primitive types by making it generic.

enum JsonType {
    boolean = "boolean",
    number  = "number",
    string  = "string",
}

type Constraint<T> = (x:T) => boolean

function validate<T>(value:unknown, type:JsonType, constraint?:Constraint<T>) : T {
    const value : unknown = this.jsonObject[name]
    if (typeof value === type) {
        const v = value as T
        if (constraint === undefined || constraint(v)) {
            return v
        }
    }
    throw new Error(`Invalid ${type} or failed constraint.`)
}

It seems redundant that we specify a parameter called type as well as a type parameter T, but this is necessary because TypeScript is “fully erased,” meaning its type information is not available at run-time.  We shall also see examples below where type and T are not the same.  We can now use this single function to implement a function that defines the JSON validation rules for the Person interface, completely and concisely as follows.  This includes a constraint that the ID be a positive integer.

type JsonObject = Record<string, unknown>
type JsonValidationRules<T> = (json:JsonObject) => T

const isPositiveInteger : Constraint<number> = (x) => x > 0 && Number.isInteger(x)

const jvrPerson:JsonValidationRules<Person> = (json) => {
    return {
        id   : validate<number>(json["id"], JT.number, isPositiveInteger),
        name : validate<string>(json["name"], JT.string),
        free : validate<boolean>(json["free"], JT.boolean)
    }
}

In this example all the attributes are required, but we will certainly need to handle optional attributes.  For this purpose, we add a second function to allow and check for this before calling validate.

function validateOpt<T>(value:unknown, type:JsonType, constraint?:Constraint<T>) : T | undefined {
    if (value === undefined || value === null) {
        return undefined
    }  
    return validate<T>(value, type, constraint)
}

At this point we can validate any flat JSON (no arrays, no objects).

The General Case With Deep JSON

Extending our Person object with an Address object gives the following.

enum StateCode {
  OH = "OH",
  ...
}

interface Address {
    city      : string
    stateCode : StateCode
}

interface Person {
    id      : number
    name    : string
    address : Address
}

With the functional approach taken to define the validation rules, we can now add a validateObject function which takes a rules function that is identical in construction to the top-level JSON validation rules function we created above.

function validateObject<T>(value:unknown, jvr:JsonValidationRules<T>) : Readonly<T> {
    if (typeof value === "object" && value !== null) {
        const jsonObject = value as JsonObject
        return jvr(jsonObject)
    }
    throw new Error("Invalid JSON object.")
}

Then adding the address to the previous version of jvrPerson gives this.  This also includes an example of validating against an enum, which is a case where type and T are different.

const isStateCode : Constraint<string> = (x) => x in StateCode

const jvrPerson = (json:JsonObject) : Person => {
    return {
        id   : validate<number>(json["id"], JT.number, isPositiveInteger),
        name : validate<string>(json["name"], JT.string),
        free : validate<boolean>(json["free"], JT.boolean)
        address : validateObject<Address>(json["address"], (json:JsonObject) => {
            return {
                city      : validate<string>("city", JT.string),
                stateCode : validate<StateCode>("stateCode", JT.string, isStateCode)
            }
        })
    }
}

The other non-primitive data type supported by JSON is the array.  For this, the specified data type refers to the type of each element of the array.  See the source code for a similar function for handling arrays of objects.

function validateArray<T>(value:unknown, type:JsonType) : ReadonlyArray<T> {
    if (Array.isArray(value)) {
        const a = value as Array<unknown>
        for (const s of a) {
            if (typeof s !== type) {
                throw  new Error("Invalid JSON array element.")
            }
        }
        return a as ReadonlyArray<T>
    }
    throw new Error("Invalid JSON array.")
}

Putting It All Together

We now have JSON validation functions to cover almost every case, but there is room for streamlining their use.  Toward that end, we bundle them as methods in a new class JsonValidator, which will be instantiated for each JSON object using the following constructor.

constructor(private readonly jsonObject:Readonly<JsonObject>) {}

By wrapping the JSON object in an instance of this class, we can restructure our validation methods to require simply the name of the attribute, not the value.  So a validation rule such as

id : validate<number>(json["id"], JT.number, isPositiveInteger)

will become

id : v.validate<number>("id", JT.number, isPositiveInteger)

where v refers to a JsonValidator instance.  We also modify JsonValidationRules to take one of these as input.

type JsonValidationRules<T> = (jv:JsonValidator) => T

The example below puts all of this together into a new defintion of our Person interface.

import { JsonType as JT, JsonValidationRules, isPositiveInteger } from "@cnz.com/ts-json"
import { Address, jvrAddress }                                    from "./Address"

interface Person {
    id         : number
    name       : string
    free       : boolean
    address    : Address
    others?    : ReadonlyArray<number>,
    addresses? : ReadonlyArray<Readonly<Address>>
}

const jvrPerson:JsonValidationRules<Person> = (v) => {
    return {
        id        : v.validate<number>("id", JT.number, isPositiveInteger),
        name      : v.validate<string>("name", JT.string),
        free      : v.validate<boolean>("free", JT.boolean),
        address   : v.validateObject<Address>("address", jvrAddress),
        others    : v.validateArrayOpt<number>("others", JT.number),
        addresses : v.validateArrayOfObjectsOpt<Address>("addresses", jvrAddress)
    }  
}

export { Person, jvrPerson }

And here is an example using this to validate a JSON object (with the validation functions now throwing a custom JsonError).

import { JsonError, JsonValidator } from "@cnz.com/ts-json"

const jsonPerson = {
    id   : 6,
    name : "Patrick McGoohan",
    free : true,
    address : {
        city      : "The Village",
        stateCode : "NY"
    },
    others : [2, 3, 4, 5],
    addresses : [
        {
            city      : "Cincinnati",
            stateCode : "OH"
        },
        {
            city      : "Chicago",
            stateCode : "IL"
        }
    ]
}

const jv = new JsonValidator(jsonPerson)
try {
    const person:Person = jvrPerson(jv)
} catch (e:unknown) {
    if (e instanceof JsonError) {
        console.log(`JSON validation error: ${e.message}`)
    } else {
        console.log("JSON validation failed with unknown error.")
    }
}

Conclusion

In addition to being concise and readable, by using TypeScript as the engine of validation we have achieved the same level of reliability as libraries that generate validation code from a TypeScript interface.  And we have done this without the drawbacks of code generation.  I would recommend experimenting with validation rule functions that do not match the interface to see the resulting TypeScript errors.

It is also worth noting that there is no special handling for dates, UUIDs, etc.  I would say this comes under the domain of mapping, not validation, and should be handled separately.  This library does, however, allow renaming attributes, which might be considered mapping, but is very convenient for dealing with JSON having spinal-case attributes.

The JSON validation described here has been packaged as a library and published under an open source license as the package @cnz.com/ts-json.  The complete source code is available at https://gitlab.com/lfrost/ts-json.