Generate Validator and Type Def with AJV

Here's how to generate both a TypeScript interface and a validator function with a schema with a tool called ajv.

Importance of Validation

TypeScript is going to provide type definitions that allow build-time static analysis of your code. It helps you detect if you've written code-level errors.

After you've built and deployed your code, it will be running in the real world. Type data has been stripped away, and it doesn't matter any more. Now your code will encounter real-world data, say, in the form of network requests from the outside. You want to verify that the data responses from such requests match your expectations in code. Thus, around these response handlers, you'll write a validator for data that fails fast in the case of unexpected data that your code isn't designed to handle.

One Source of Truth

When you define the shape of data you expect, it's nice to only have to do that once. That's what makes this solution neat.

You define the schema and generate the TypeScript interface from the schema. You don't have to duplicate the definition manually in a typedef and then keep it in sync with the schema as changes are made over time.

Get the Tool

First, you want to install the lib in your project:

npm install ajv

Choose a Schema Format

ajv supports multiple formats, but the one you want is the JSON Type Definition (JTD) format. This is the format that supports TypeScript typedef generation.

Specify your Schema

First step in code is to create your schema. Let's say you're requesting a person from a /person/:id endpoint. Here's a potential schema for what comes back:

const PERSON_SCHEMA = {
  properties: {
    id: { type: 'string' },
    name: { type: 'string' },
  }
} as const

as const is important, giving you the narrowest type that TypeScript can determine.

Also note that in this form, only id and name fields are allowable on the object. If you want to allow additional properties, you can set additionalProperties: true (more on that later).

Generate the Type

Now let's spit out a TypeScript type from the schema using a great JTDDataType utility:

import { JTDDataType } from 'ajv/dist/jtd'
type Person = JTDDataType<typeof PERSON_SCHEMA>

Generate the Validator

import Ajv from 'ajv/dist/jtd'
const ajv = new Ajv()
const validatePerson = ajv.compile<Person>(PERSON_SCHEMA)

Fetch and Validate Example

Now you're ready to do all the fetching and validating you want:

import { ErrorObject } from 'ajv/dist/jtd'

async function fetchPerson(id: string): Promise<Result<Person>> {
  const res = await fetch(`/person/${id}`)
  const person = await res.json()
  return res.ok
      ? validatePerson(person)
      ? { ok: true, value: person }
      : { ok: false, error: new SchemaValidationError(validatePerson.errors) }
      : { ok: false, error: new Error(person.error) }
  }
}

class SchemaValidationError extends Error {
  errors: ErrorObject[]
  constructor(errors: ErrorObject[] | null | undefined) {
    super('Schema validation error')
    this.errors = errors ?? []
  }
}

For more details on the Result type usage in the above example, see the article on handling errors with Result types.

Display Schema Validation Errors

You can expose validation errors to your users however you'd like. Here's an example React component:

function SchemaErrors(props: { errors: ErrorObject[] }) {
  return (
    <ol className="list-decimal pl-4">
      {props.errors.map((err) => (
        <li key={err.instancePath}>
          <span className="inline-flex flex-col gap-2">
            <span className="font-600">{err.message}</span>
            <span className="text-gray-500">{err.schemaPath}</span>
            <pre className="overflow-auto rounded-10 border border-solid border-gray-700 px-4 py-1">
              {formatData(err.data)}
            </pre>
          </span>
        </li>
      ))}
    </ol>
  )
}

function formatData(data: unknown): string {
  if (typeof data === 'object' && data !== null) {
    return JSON.stringify(data, null, 2)
  }
  return String(data)
}

Use Your Typedef

Now that you've requested data at runtime, validated it, and returned it as a Person type, you're ready to use the person object in your code. For instance:

const person = await fetchPerson('123')
if (person.name === 'Jake') {
  writeBlogPost(person)
}

You've done it!

Well, that was the easy case. For a bit more complexity, we'll take a look at how to handle additional properties in a later article.