Functional schema-driven JavaScript object validation.
Look up any Object validation library in JavaScript. You are going to find a class-bloated rigid abstraction that handles complexity by introducing even more complexity.
How I see Object validation instead:
- Validation result is a function from schema and data.
- Validation is not concerned with any logic around error messages.
All these criteria can be summarized as: validation should be functional. Functional composition gives you flexibility and power than no class abstractions could ever match.
It's recommended to use this library alongside any functional utilities library (i.e. lodash, ramda). They can save you a lot of time on writing custom resolvers.
Object validation happens based on the given schema. A validation schema is an Object where each key represents such property in the actual data Object. A value of the key may be one of the following:
- Resolver function that returns a validity of a data value.
- Nested schema Object.
Applying a schema returns the list of validation errors.
Each validation error has this structure:
interface Error {
// Pointer to the related property in the actual Object.
pointer: string[]
// Field that doesn't match a schema may be in
// one of the two possible stats:
// - missing. Expected, but not present in the data.
// - invalid. Present in both, but not matching the resolver.
status: 'missing' | 'invalid'
// (Optional) The actual value of the validated property
// Present if a property has value.
value?: any
// (Optional) Rule name, in case of rejecting named resolver.
// Present if given a named resolver.
rule?: string
}
Each key in a schema corresponds to such property in the actual data Object. Each schema value is a resolver function that accepts an actual data value and returns a Boolean
verdict.
import { useSchema } from 'reach-schema'
useSchema(
{
firstName: (value) => value === 'john',
lastName: (value) => value === 'locke',
age: (value) => age > 18,
},
{
firstName: 'john',
age: 16,
},
)
[
{
"pointer": ["lastName"],
"status": "missing"
},
{
"pointer": ["age"],
"status": "invalid",
"value": 16
}
]
If a schema key equals an Object literal, that nested Object is expected in the data. This allows to validate deeply nested structures.
import { useSchema } from 'reach-schema'
useSchema(
{
billingData: {
country: (value) => ['UK', 'ES'].includes(value),
},
},
{
billingData: {
country: 'US',
},
},
)
[
{
"pointer": ["billingData", "country"],
"status": "invalid",
"value": "US"
}
]
A resolver function may also return a Record<string, boolean>
that describes multiple validation rules applied to a single property. The value must satisfy all the rules to be valid. Each resolver corresponding to a validation criteria is called named resolver.
import { useSchema } from 'reach-schema'
useSchema(
{
password: (value) => ({
minLength: value.length > 5,
capitalLetters: /[A-Z]{2}/.test(value),
oneNumber: /[0-9]/.test(value),
}),
},
{
password: 'DeMo',
},
)
[
{
"pointer": ["password"],
"status": "invalid",
"value": "DeMo",
"rule": "minLength"
},
{
"pointer": ["password"],
"status": "invalid",
"value": "DeMo",
"rule": "oneNumber"
}
]
Validation logic is decoupled from error messages for a number of reasons:
- Separation of concerns. Think of validation logic as a business logic and error messages as a view layer. Those are usually kept separated to have each do its own job and do it well.
- Dynamic messages. Error messages are often consumed by a client and are localized or context-dependent. If separated from the validation logic, they can be derived from the validation errors depending on a locale, or any other condition.
To check that a property exists in the data Object provide it in the validation schema and make its resolver always return true
.
import { useSchema } from 'reach-schema'
useSchema(
{
// The property "email" is required in the data Object,
// but is always valid, no matter the value.
email: () => true,
},
{
email: 'admin@example.com',
},
)
Optional, or week validation, is the one applied only when the mentioned property is present in the actual data. This marks such property as optional, but still applies a provided structure or resolver(s) when the property is present.
import { useSchema, optional } from 'reach-schema'
useSchema(
{
firstName: optional((value) => value.length > 1),
billingData: optional({
address: (value) => value.includes('st.'),
firstName: optional((value) => value.length > 1),
}),
},
{
billingData: {
address: 'Invalid address',
},
},
)
[
{
"pointer": ["billingData", "address"],
"status": "invalid",
"value": "Invalid address"
}
]
Note that
firstName
andbillingAddress.firstName
are missing in the actual data, but that produces no errors, because both properties are optional.