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.