Strong Type Checks with AJV Additional Properties

It's tricky to make a forgiving validator and a unforgiving type definition with AJV's JTDDataType. Here's a method to the madness.

I've previously gone over the happy path to generate typedefs from AJV schemas. Let's build off of that example.

Additional Properties

Let's say that we have person REST endpoint that has properties we care about and some that we don't. In other words, we need to validate that we get the properties we need, and we'll get but ignore the rest. When it comes to specifying JTD schemas, this is what additionalProperties means. It means that there are properties I'm not explicitly listing in my schema, and that's ok. I'll happily ignore them when they come back from the REST endpoint.

The example schema looks like this:

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

The data returned from the server might look like this, with the extra age property:

{
  "id": "123",
  "name": "Alice",
  "age": 32
}

Let's write out the validator and type definition:

import Ajv from 'ajv'
const ajv = new Ajv()

type Person = JTDDataType<typeof PERSON_SCHEMA>

const validatePerson = ajv.compile<Person>(PERSON_SCHEMA)

async function fetchPerson(id: string) {
  const response = await fetch(`/person/${id}`)
  const data = await response.json()
  if (validatePerson(data)) {
    // valid case ...
  }
  // ...
}

This all works great. Our example person data will validate just fine.

The Typedef Problem

The problem comes later in the Person type. It's too forgiving. Like the validator, it also thinks it's ok to have any additional properties on the type.

It's forgiving because the JTDDataType-generated type has appended a & Record to the end of the typedef. This is a TypeScript way of saying "and any other properties are allowed."

For instance, if we try to access non-existent properties on the Person object, TypeScript won't know to complain:

const person: Person = await fetchPerson('123')

function calcBmi(person: Person) {
  return person.weight / Math.pow(person.height, 2) * 703
}

person.weigth and person.height will be of type unknown, but tsc won't tell us that these fields don't exist on the Person type.

The most we'll have to do to make tsc happy is to cast the unknowns:

function calcBmi(person: Person) {
  return Number(person.weight) / Math.pow(Number(person.height), 2) * 703
}

This is the basic problem. I don't want my typedef to allow for any unknown additional properties. Usually I write a typedef so that it's stricter and not as open as Record. Thus I think that the default behavior of JTDDataType from AJV is too open and forgiving.

A Solution

In the best case, AJV will adjust the default behavior in the future for JTDDataType to not append & Record to the end of the type automatically. There is a case for wanting this openness in a typedef, but it's what seems the minority case, and a developer could add that themselves as needed.

The next best fix I can see is to parameterize the creation of the schema. This maintains a single location for the schema definition, and it allows creating it differently in the validation compared to the typedef case, as seen here:

function personSchema(additionalProperties?: boolean = false) {
  return {
    additionalProperties,
    properties: {
      id: { type: 'string' },
      name: { type: 'string' },
    },
  } as const
}

const EXACT_PERSON_SCHEMA = personSchema(false)
type Person = JTDDataType<typeof EXACT_PERSON_SCHEMA>
const validatePerson = ajv.compile<JTDDataType<Person>>(personSchema(true))

personSchema(true) creates a loose schema for validation, where we commonly don't care about additional data.

personSchema(false) creates a strict schema for type checking, where we commonly want to know if we stray outside our typedef in coding.

Also note that additionalProperties can be listed in an object next to any properties property, so you'll want to apply this strategy for any nested objects with additionalProperties as well.

To vote for an adjustment to the JTDDataType behavior, chime in on the open issue at ajv-validator/ajv#2405.

To see the error cases in action, check out the jaketrent/demo-ajv-ts-issue repo.

Do you run into this issue? What do you feel the best solution is?