No Time Dad

A blog about web development written by a busy dad

TypeScript Validate JSON Example

It’s surprising to me how many times in my career I’ve had to validate the contents of a JSON file. Usually it’s for some configuration settings, or pre-loading data into a database. And yet I haven’t really found a painless way of doing it. This TypeScript example is probably the closest I’ve come to tolerable JSON validation, and even it isn’t without its pain points. Maybe I just need to accept the fact that validating JSON is annoying and there is no escaping that.

TypeScript seems like a good candidate to validate JSON because of it’s robust type system, and because JSON stands for JavaScript Object Notation after all. But no, most of the magic in using TypeScript to validate JSON doesn’t come from the language itself, but from third-party libraries that use decorators and meta-programming to infer types. In fact, the TypeScript team have been explicit about the fact that the language wasn’t designed to be used for data validation out of the box.

Working Solution

Below is small working example of how I’d validate JSON with TypeScript.

Dependencies & Important TS Flags
// package.json
...
"devDependencies": {
  ...
  "typescript": "^4.6.4"
},
"dependencies": {
  ...
  "class-transformer": "^0.5.1",
  "class-validator": "^0.13.2"
}
...
// tsconfig.json
{
  "compilerOptions": {
    ...
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    ...
  }
}
Example Code
// config.json
{
  "logLevel": "error",
  "isPrimary": true,
  "port": 80,
  "rootPath": "/"
}
import config from "./config.json"
import { plainToInstance } from "class-transformer"
import { IsBoolean, IsInt, IsString, validate } from "class-validator"

class Config {
  @IsString()
  logLevel!: string

  @IsBoolean()
  isPrimary!: boolean

  @IsInt()
  port!: number

  @IsString()
  rootPath!: string
}

const configInstance: Config = plainToInstance(Config, config)
validate(configInstance).then(errors => console.log(errors))

Getting to the solution

The validation magic here is being done by the class-validator library. The way this library works is you need to add its type decorators to each property based on what you’re expecting the type to be. For example, isPrimary: boolean is expected to be a bool so I’d use class-validator’s IsBoolean decorator to enforce that type. The same is true for any other built-in TypeScript type. The patten is @Is<type>(), unless you’re doing something custom or nested, which I’m not here so I won’t go into it.

As the name class-validator implies, this library performs validation against TypeScript classes. But, a JSON object is not a class. So, before I can use this library, I need to convert my JSON object to a class. This is done by using the class-transformer library which is made by the same creator as class-validator, conveniently.

import config from "./config.json"

class Config {
  logLevel!: string
  isPrimary!: boolean
  port!: number
  rootPath!: string
}

Above I’ve defined a Config class that is the same shape as my config.json data. I’ve used TypeScrip’s definite assignment assertion for each property in the class since there isn’t a constructor and the properties aren’t initialized elsewhere in the class. Which is interesting because at this point, each property definitely won’t be initialized by the class-transormer library. For example, if the rootPath key value pair was missing in the config.json file the class-transformer library would just create the class without that property. The rootPath property would be undefined.

So, the class at this point should technically look as follows:

class Config {
  logLevel: string | undefined
  isPrimary: boolean | undefined
  port: number | undefined
  rootPath: string | undefined
}

But, using class-transformer is really just the first step in the process for the Config class. I’m eventually going to be adding the class-validator decorators to the properties which is going to make them required by default. Which is why the final version of the Config class uses the definite assignment operator, which tells the compiler that the properties will eventually be given a value.

Now it’s just a matter of adding the correct decorator for each property type. I really wish this could be inferred, but I guess it’s just not meant to be. So, decorator it is.

Below is how the Config class looks with the decorators added.

import config from "./config.json"
import { plainToInstance } from "class-transformer"
import { IsBoolean, IsInt, IsString } from "class-validator"

class Config {
  @IsString()
  logLevel!: string

  @IsBoolean()
  isPrimary!: boolean

  @IsInt()
  port!: number

  @IsString()
  rootPath!: string
}

const configInstance: Config = plainToInstance(Config, config)
console.log(configInstance)
// Config { logLevel: 'error', isPrimary: true, port: 80, rootPath: '/' }

Now I can use the class-transformer library to create an instance of the Config class with data from config.json. This is done using the plainToInstance method. Which is slightly annoying because the class-transformer docs use the plainToClass method but in the source code itself that method is deprecated. But, either method works. I just wouldn’t put the code into production using plainToClass.

The plainToInstance method ignores the decorators and instantiates the class with the data from the config.json file. Once the instance is created, I can then use the class-validator validate method to check if the class created based on the JSON is valid.

import config from "./config.json"
import { plainToInstance } from "class-transformer"
import { IsBoolean, IsInt, IsString, validate } from "class-validator"

class Config {
  @IsString()
  logLevel!: string

  @IsBoolean()
  isPrimary!: boolean

  @IsInt()
  port!: number

  @IsString()
  rootPath!: string
}

const configInstance: Config = plainToInstance(Config, config)
validate(configInstance).then(errors => console.log(errors))

If the JSON is valid, the validate method will return an empty array because there aren’t any issues. If I change the JSON to be invalid by removing the required rootPath key value pair from config.json then I’d get an error object in the errors array from the validate method.

// Invalid JSON according to the Config class definition
{
  "logLevel": "error",
  "isPrimary": true,
  "port": 80
}

Error response from the above invalid JSON is shown below. Note that the target class is missing the rootPath property.

[
  ValidationError {
    target: Config { logLevel: 'error', isPrimary: true, port: 80 },
    value: undefined,
    property: 'rootPath',
    children: [],
    constraints: { isString: 'rootPath must be a string' }
  }
]

Aside from the Config class definition, it’s really just two lines of code to get basic JSON validation working with TypeScript. It did require external libraries, but that’s to be expected with most JavaScript projects these days. Both the class-validator and class-transformer libs have a huge amount of features worth exploring. An interesting feature of class-validator worth pointing out is the fact that the type decorators aren’t a strict requirement, and a JSON config with expected type definitions can be used instead. Which is pretty cool.