Skip to content

Schema validation

Table of Contents

About schemas

** Recommendation: Use Zod schemas when possible**

Zod schemas are the preferred choice for validation as they:

  • Work out of the box without additional dependencies
  • Provide better TypeScript integration
  • Are more performant
  • Have a more intuitive API

JSON Schema validation is still supported but requires explicitly providing an AJV instance.

JSON Schema is a vocabulary for annotating and validating data in js. More about JSON Schema on json-schema.org

A schema can be used from the simplest description of the data type of a value:

{
type: 'string'
}

Results in:

"Foo"

To an object with both rules for required fields and validation rules for single values:

{
type: "object",
properties: {
textField: { type: 'string', minLength: 5 },
numberField: { type: 'number', maximum: 100 },
},
required: ['textField']
}

Results in:

{
"textField": "abcde",
"numberField": 123
}

Using schema with Form.Handler (DataContext)

These two examples will result in the same validation for the user:

<Form.Handler data={user}>
<Field.String path="/name" label="Name" minLength={3} required />
<Field.Email path="/email" label="E-mail" required />
<Field.Number
path="/birthyear"
label="Birth year"
minimum={1900}
maximum={2023}
required
/>
</Form.Handler>

vs.

import { Form, Field, z, makeAjvInstance } from '@dnb/eufemia/extensions/forms'
const schema = z.object({
name: z.string().min(3),
email: z.string().email(),
birthyear: z.number().min(1900).max(2023),
})
<Form.Handler data={user} schema={schema}>
<Field.String path="/name" label="Name" />
<Field.Email path="/email" label="E-mail" />
<Field.Number path="/birthyear" label="Birth year" />
</Form.Handler>

Using JSON Schema (Ajv)

import { JSONSchema, makeAjvInstance } from '@dnb/eufemia/extensions/forms'
const ajv = makeAjvInstance()
const schema: JSONSchema = {
properties: {
name: { minLength: 3 },
email: { type: 'string' },
birthyear: { minimum: 1900, maximum: 2023 },
},
required: ['name', 'email', 'birthyear'],
}
<Form.Handler data={user} schema={schema} ajvInstance={ajv}>
<Field.String path="/name" label="Name" />
<Field.Email path="/email" label="E-mail" />
<Field.Number path="/birthyear" label="Birth year" />
</Form.Handler>

This makes it possible to create a uniform, testable description and requirements specification for the data, which can be tested independently of frontend code, and used across systems, e.g. frontend and backend.

Also, note you can describe the schema without using the type property, as the type is inferred from schema type. More on that topic in the Ajv docs.

Fields which are disabled or read-only

Fields which have the disabled property or the readOnly property, will skip validation.

Zod schemas and TypeScript

For better TypeScript integration, consider using Zod schemas instead:

import { z } from '@dnb/eufemia/extensions/forms'
const schema = z.object({
name: z.string().min(3),
email: z.string().email(),
birthyear: z.number().min(1900).max(2023),
})
// The type is automatically inferred
type UserData = z.infer<typeof schema>

JSONSchema and TypeScript

You can import the JSONSchema type from the @dnb/eufemia/extensions/forms package.

import { JSONSchema } from '@dnb/eufemia/extensions/forms'

It's a shorthand for JSONSchema7.

You can also use the utility JSONSchemaType type, so you can validate your data types.

NB: This requires strictNullChecks in the tsconfig settings to be true.

import { JSONSchemaType } from '@dnb/eufemia/extensions/forms'
type MyData = {
foo: number
bar?: string
}
const schema: JSONSchemaType<MyData> = {
type: 'object',
properties: {
foo: { type: 'integer' },
bar: { type: 'string', nullable: true },
},
required: ['foo'],
}

Complex schemas

In addition to basic validation as in the example above, JSON Schema can be used for more complex. Examples of definitions supported by the standard are:

  • Requirement that the object must not have other properties than those defined in properties.
  • Nested data structures and combinations of objects and arrays with rules for array elements (fixed or repetitive elements).
  • Regular expressions for the syntax of individual values.
  • Enum (a set of valid values).
  • Rules for the number of elements in arrays.
  • Rules for the number of properties in objects.
  • Predefined format rules (eg 'uri', 'email' and 'hostname').
  • Logical operators such as 'not', 'oneOf', 'allOf' and 'anyOf' which can be filled with rules for all or part of the data set.
  • Rule set based on the content of values (if-then-else).
  • Rules (sub-schemas) that become applicable if a given value is present.
  • Reuse within the definition, such as one and the same object structure being used as a definition for several locations in a structure.

To learn more about what is possible with the JSON Schema standard, see json-schema.org.

Custom Ajv instance and keywords

You can provide your custom validate function with your own keywords to your schema. Below are two examples of how to do that.

First, you need to create your won instance of Ajv:

import { makeAjvInstance } from '@dnb/eufemia/extensions/forms'
import Ajv from 'ajv/dist/2020'
const ajv = makeAjvInstance(
new Ajv({
strict: true,
allErrors: true,
}),
)

Then you add your custom keyword to the Ajv instance:

// Add a custom keyword 'isEven'
ajv.addKeyword({
keyword: 'isEven',
validate: (schema, value) => {
// Check if the number is even.
return value % 2 === 0
},
})
// Now we can use the 'isEven' keyword in our schema.
const schema = {
type: 'object',
properties: {
myKey: {
type: 'string',
isEven: true, // The number must be even.
},
},
} as const

Use as const to make sure the schema is not inferred as JSONSchema7 but as a literal type.

And finally add the Ajv instance to your form:

import {
Form,
Field,
makeAjvInstance,
} from '@dnb/eufemia/extensions/forms'
render(
<Form.Handler schema={schema} ajvInstance={makeAjvInstance()}>
<Field.String path="/myKey" value="1" validateInitially />
</Form.Handler>,
)

Custom Ajv keyword in a field

Here is another example of a custom keyword, used in one field only:

import {
Form,
Field,
makeAjvInstance,
} from '@dnb/eufemia/extensions/forms'
const ajv = makeAjvInstance({
strict: true,
allErrors: true,
})
ajv.addKeyword({
keyword: 'notEmpty',
validate: (schema, value) => {
return value.length > 0
},
})
const schema = {
type: 'string',
notEmpty: true, // The value must be more than one character.
} as const
render(
<Form.Handler ajvInstance={ajv}>
<Field.String
schema={schema}
path="/myKey"
value=""
validateInitially
/>
</Form.Handler>,
)

You can find more info about error messages in the Error messages docs.

Custom error messages

When having a custom keyword, you can provide custom error message on four levels with the errorMessage or errorMessages property:

  1. On the schema level.
  2. On the Form.Handler (Provider) level.
  3. On the Form.Handler (Provider) level with a JSON Pointer path.
  4. On the field level.

The levels are prioritized in the order above, so the field level error message will overwrite all other levels.

Here is an example of how to do that:

const schema = {
type: 'string',
notEmpty: true, // The value must be more than one character.
// Level 1
errorMessage: 'You can provide a custom message in the schema itself',
} as const
render(
<Form.Handler
ajvInstance={ajv}
errorMessages={{
// Level 2
notEmpty: 'Or on the provider',
'/myKey': {
// Level 3
notEmpty: 'Or on the provider for just one field',
},
}}
>
<Field.String
schema={schema}
path="/myKey"
value=""
validateInitially
errorMessages={{
// Level 4
notEmpty: 'Or on a single Field itself',
}}
/>
</Form.Handler>,
)

You can find more info about error messages in the Error messages docs.

Generate schema from fields

You can also generate a Ajv schema from a set of fields, by using the log property on the Tools.GenerateSchema component. I will console log the generated schema.

import { Form, Field, Tools } from '@dnb/eufemia/extensions/forms'
render(
<Form.Handler>
<Tools.GenerateSchema log>
<Field.String path="/myString" pattern="^[a-z]{2}[0-9]+$" required />
</Tools.GenerateSchema>
</Form.Handler>,
)
// console.log output:
{
"properties": {
"myString": { "type": "string", "pattern": "^[a-z]{2}[0-9]+$" }
},
"required": ["myString"],
"type": "object"
}

Or by using the generateRef property on the Tools.GenerateSchema component. Here is an example of how to do that within a test:

import { Form, Field, Tools } from '@dnb/eufemia/extensions/forms'
it('should match generated schema snapshot', () => {
const generateRef = React.createRef<>()
render(
<Form.Handler>
<Tools.GenerateSchema generateRef={generateRef}>
<Field.Name.First path="/firstName" />
<Field.Name.Last path="/lastName" minLength={2} required />
</Tools.GenerateSchema>
</Form.Handler>,
)
const { schema } = generateRef.current()
expect(schema).toMatchInlineSnapshot(`
{
"type": "object",
"properties": {
"firstName": {
"type": "string",
"pattern": "^[\\p{L}\\p{M} \\-]+$",
},
"lastName": {
"type": "string",
"minLength": 2,
"pattern": "^[\\p{L}\\p{M} \\-]+$",
},
},
"required": [
"lastName",
],
}
`)
})