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 unknown
s:
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?