Object field validators in Typescript
Before you pull in the next hot new validation library. Ask yourself this question. Do I need it? The answer is no, field validation is quite easy. Let me show you how to create an extensible object field validator.
Whats an object field validator?
Ok, so let's work with the following scenario. You have a user registration form. It's represented in your code as the following type.
type UserForm = { email: string, password: string }
Typescript will ensure that you get an error message when you try to assign a number
instead of
a string
for the email field. But this object represents form data, aka user input. So it could
be anything. We should validate that it matches our criteria. For example the email should be of a
valid email format, and the password should be no less that 8 characters in length.
This where an object field validator comes into play. It takes an object and applies the given validators to the object fields. Telling us that, yes this user form contains a valid email address etc…
Usage
Let's start by defining how we want to use it. I'd like to make a composable validator. This would be
a function that takes a generic type and produces a validator for that type. The type in question is
the above UserForm
. The method used to compose the validator should take an object that contains a
list of field validators for each of the UserForm
fields. These field validators should themselves
be composable methods.
When the composed validator is called with our UserForm
instance, it should return an object representing
any errors that were found.
The end result should be a clean api for creating object field validators. To me the below example is perfect.
type UserForm = { email: string, password: string }
const userFormValidator = createFieldValidator<UserForm>({
email: [stringValidators.email((email: string) => `${email} is not a valid email address`)],
password: [
stringValidators.min(8, () => `Password must be greater than 8 characters in length`),
stringValidators.max(250, () => `Password must be less than 8 characters in length`),
]
})
const validated = userFormValidator({ email: 'not an email', password: '2 short' })
The validated
variable above will hold the following value.
{
input: {
email: "not an email",
password: "2 short",
},
errors: {
email: [ "'not an email' is not a valid email address" ],
password: [ "Password must be at least 8 characters long" ],
},
hasErrors: true,
}
This makes it easy to check if there are errors present, using the hasErrors
field, and then access a list
of error messages for each field.
Implementation
I'd like to believe that you are a top programmer and just want to see the code, without me trying to clumsly explain it. The code doesn't tell lies, I do. So here is the code.
<T extends Object>
?Object.entries
needs to take an Object
or an ArrayLike
entity. So it was just to tell the type checker that T
is some sort of Object
.
type ErrorMap = { [key: string]: string[] }
type ErrorMessageCallback = (input: string) => string
type ValidatorMap = { [key: string]: Function[] }
type Validated<T> = { input: T, errors: ErrorMap, hasErrors: boolean }
export const createFieldValidator = <T extends Object>(validators: ValidatorMap) => (input: T): Validated<T> => {
const errors: ErrorMap = {}
let hasErrors = false
for (const [key, value] of Object.entries(input)) {
if (validators[key]) {
for (const validator of validators[key]) {
const error = validator(value)
if (error) {
hasErrors = true
if (errors[key]) {
errors[key].push(error)
} else {
errors[key] = [error]
}
}
}
}
}
return { input, errors, hasErrors }
}
export const stringValidators = {
isString: (msg?: ErrorMessageCallback) => (input: string) => {
if (!(typeof input == 'string')) return msg ? msg(input) : 'Not a string'
},
min: (n: number, msg?: ErrorMessageCallback) => (input: string) => {
if (input.length < n) return msg ? msg(input) : `Must be greater than ${n} characters in length`
},
max: (n: number, msg?: ErrorMessageCallback) => (input: string) => {
if (input.length > n) return msg ? msg(input) : `Must be less than ${n} characters in length`
},
email: (msg?: ErrorMessageCallback) => (input: string) => {
const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
if (!emailRe.test(input.toLowerCase())) return msg ? msg(input) : 'Not a valid email address'
}
}