UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

998 lines (871 loc) 25.1 kB
--- title: 'Form.SchemaValidation' description: 'Schema validation can be done with a JSON Schema which makes it possible to describe the data structure and validation needs, both for the individual value, and more complex rules across the data set.' version: 10.104.0 generatedAt: 2026-04-17T18:46:12.395Z checksum: 090b7d977ba4be5e2c4c04d199a30a4048416c59f443a56985df2f80629d9c40 --- # Form.SchemaValidation ## Table of Contents - [About schemas](#about-schemas) - [Using schema with DataContext](#using-schema-with-datacontext) - [Fields which are disabled or read-only](#fields-which-are-disabled-or-read-only) - [JSONSchema and TypeScript](#jsonschema-and-typescript) - [Complex schemas](#complex-schemas) - [Custom Ajv instance and keywords](#custom-ajv-instance-and-keywords) - [Custom Ajv keyword in a field](#custom-ajv-keyword-in-a-field) - [Custom error messages](#custom-error-messages) - [Generate schema from fields](#generate-schema-from-fields) ## 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](https://json-schema.org/) A schema can be used from the simplest description of the data type of a value: ```js { type: 'string' } ``` Results in: ```json "Foo" ``` To an object with both rules for required fields and validation rules for single values: ```js { type: "object", properties: { textField: { type: 'string', minLength: 5 }, numberField: { type: 'number', maximum: 100 }, }, required: ['textField'] } ``` Results in: ```json { "textField": "abcde", "numberField": 123 } ``` ## Using schema with Form.Handler (DataContext) These two examples will result in the same validation for the user: ```jsx <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. ```tsx 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)** ```tsx 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](https://ajv.js.org/guide/typescript.html#utility-types-for-schemas). ## 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: ```ts 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. ```ts 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. ```ts 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 (e.g., '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](https://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: ```ts 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: ```ts // 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: ```tsx 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: ```tsx 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](/uilib/extensions/forms/Form/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: ```tsx 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](/uilib/extensions/forms/Form/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. ```tsx 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> ) ``` ```json // 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: ```tsx 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", ], } `) }) ``` - [Demos](#demos) - [Schema for single field](#schema-for-single-field) - [Schema for a whole data set](#schema-for-a-whole-data-set) - [Schema with if-rule](#schema-with-if-rule) - [Dependent list schema](#dependent-list-schema) - [Dependent schema across sections](#dependent-schema-across-sections) - [Dependent schema using Zod](#dependent-schema-using-zod) ## Demos ### Schema for single field ```tsx const ajv = makeAjvInstance() const schema = { type: 'string', minLength: 5, } render( <Form.Handler ajvInstance={ajv}> <Field.String schema={schema} /> </Form.Handler> ) ``` ### Schema for a whole data set ```tsx const ajv = makeAjvInstance() const schema = { type: 'object', properties: { name: { type: 'string', minLength: 2, }, address: { type: 'string', minLength: 3, }, }, required: ['name', 'address'], } render( <Form.Handler data={{ address: 'Prefilled address', }} schema={schema} ajvInstance={ajv} > <Form.Card bottom="small"> <Form.MainHeading>Company information</Form.MainHeading> <Field.String path="/name" label="Name" /> <Field.String path="/address" label="Address" /> </Form.Card> <Form.SubmitButton /> </Form.Handler> ) ``` ### Schema with if-rule ```tsx const ajv = makeAjvInstance() const schema = { type: 'object', properties: { name: { type: 'string', }, customerType: { type: 'string', enum: ['corporate', 'private'], }, companyName: { type: 'string', }, }, if: { properties: { customerType: { enum: ['corporate'], }, }, required: ['customerType'], }, then: { required: ['name', 'companyName'], }, else: { required: ['name'], }, } render( <Form.Handler schema={schema} ajvInstance={ajv}> <Form.Card> <Form.MainHeading>Customer information</Form.MainHeading> <Field.String path="/name" label="Name" /> <Field.String path="/customerType" label="Customer type (corporate or private)" /> <Field.Name.Company path="/companyName" labelDescription="Company name (required for corporate customers)" /> </Form.Card> <Form.SubmitButton /> </Form.Handler> ) ``` ### Dependent list schema Becoming a new customer, this form requires at least one normal account to be added, unless the customer opens a BSU account, then normal accounts can still be added, but is optional. ```tsx const ajv = makeAjvInstance() const schema = { type: 'object', definitions: { account: { type: 'object', properties: { accountNumber: { type: 'string', pattern: '^[0-9]{11}$', }, alias: { type: 'string', minLength: 2, maxLength: 32, }, }, required: ['accountNumber'], }, }, properties: { name: { type: 'string', }, email: { type: 'string', }, phone: { type: 'string', }, accounts: { type: 'array', items: { $ref: '#/definitions/account', }, }, bsuAccount: { $ref: '#/definitions/account', }, }, oneOf: [ { properties: { accounts: { type: 'array', minItems: 1, }, }, }, { properties: { accounts: { type: 'array', minItems: 0, }, bsuAccount: { type: 'object', required: ['accountNumber'], }, }, required: ['bsuAccount'], }, ], } render( <Form.Handler data={{ accounts: [{}], }} schema={schema} ajvInstance={ajv} > <Flex.Vertical> <Form.MainHeading>Customer information</Form.MainHeading> <Form.Card> <Field.String path="/name" label="Name" /> <Field.Email path="/email" label="E-mail" /> <Field.PhoneNumber path="/phone" label="Phone number" /> </Form.Card> <Form.MainHeading>Accounts</Form.MainHeading> <Form.Card> <Form.SubHeading>Standard accounts</Form.SubHeading> <Iterate.Array path="/accounts"> <Flex.Horizontal align="flex-end"> <Field.BankAccountNumber itemPath="/accountNumber" label="Account number" /> <Field.String itemPath="/alias" label="Alias" width="medium" /> <Iterate.RemoveButton /> </Flex.Horizontal> </Iterate.Array> <Iterate.PushButton text="Add account" path="/accounts" pushValue={{}} /> <Form.SubHeading>BSU Account</Form.SubHeading> <Field.BankAccountNumber path="/bsuAccount/accountNumber" label="Account number" /> <Field.String path="/bsuAccount/alias" label="Alias" /> </Form.Card> <Form.SubmitButton /> </Flex.Vertical> </Form.Handler> ) ``` ### Dependent schema across sections This schema validates fields across different sections based on the value of another field. ```tsx const ajv = makeAjvInstance() const counts = [1, 2, 3] const schema = { type: 'object', properties: { members: { type: 'object', properties: { numberOfMembers: { type: 'integer', }, }, required: ['numberOfMembers'], }, beneficialOwners: { type: 'object', properties: { addedExistingBeneficialOwners: { type: 'array', items: { type: 'object', properties: { name: { type: 'string', }, }, required: ['name'], }, }, }, }, }, dependentSchemas: { members: { allOf: [ // Handle count = 0: array must be empty { if: { properties: { members: { type: 'object', properties: { numberOfMembers: { const: 0, }, }, required: ['numberOfMembers'], }, }, }, then: { properties: { beneficialOwners: { type: 'object', properties: { addedExistingBeneficialOwners: { type: 'array', maxItems: 0, }, }, }, }, }, }, // Handle count = 1, 2, or 3: array must match exactly ...counts.map((count) => ({ if: { properties: { members: { type: 'object', properties: { numberOfMembers: { const: count, }, }, required: ['numberOfMembers'], }, }, }, then: { required: ['beneficialOwners'], properties: { beneficialOwners: { type: 'object', properties: { addedExistingBeneficialOwners: { type: 'array', minItems: count, maxItems: count, }, }, required: ['addedExistingBeneficialOwners'], }, }, }, })), ], }, }, } render( <Form.Handler schema={schema} ajvInstance={ajv}> <Flex.Stack> <Form.Card> <Form.MainHeading>Membership</Form.MainHeading> <Field.Number path="/members/numberOfMembers" label="Number of members (1-3)" width="small" startWith={-1} showStepControls /> </Form.Card> <Form.Card> <Form.SubHeading>Beneficial owners</Form.SubHeading> <Iterate.Array path="/beneficialOwners/addedExistingBeneficialOwners" errorMessages={{ minItems: 'You must add {minItems} existing owners.', }} animate > <Section innerSpace={{ top: 'small', bottom: 'small', }} bottom backgroundColor="lavender" > <Field.String itemPath="/name" label="Owner name {itemNo}" /> <Iterate.RemoveButton /> </Section> </Iterate.Array> <Iterate.PushButton path="/beneficialOwners/addedExistingBeneficialOwners" pushValue={{}} text="Add beneficiary" /> </Form.Card> <Form.SubmitButton text="Show errors" /> <Tools.Log label="Form data" /> <Tools.Errors label="Errors" /> </Flex.Stack> </Form.Handler> ) ``` ### Dependent schema using Zod This schema is using `Zod` for validation, and validates fields across different sections based on the value of another field. ```tsx const counts = [1, 2, 3] const ownerSchema = z.object({ name: z.string().optional(), }) const schema = z .object({ members: z .object({ numberOfMembers: z.number().int().optional(), }) .optional(), beneficialOwners: z .object({ addedExistingBeneficialOwners: z.array(ownerSchema).optional(), }) .optional(), }) .superRefine((value, ctx) => { // numberOfMembers is always required if (!value.members || value.members.numberOfMembers === undefined) { ctx.addIssue({ code: 'custom', path: ['members', 'numberOfMembers'], message: 'Field.errorRequired', }) return // stop further validation } const count = value.members.numberOfMembers // Check if count matches one of the expected values (1, 2, or 3) // This matches the AJV dependentSchemas logic with allOf and if/then if (counts.includes(count)) { // If count matches, beneficialOwners is required if (!value.beneficialOwners) { ctx.addIssue({ code: 'custom', path: ['beneficialOwners'], message: 'Field.errorRequired', }) return // stop further validation } // addedExistingBeneficialOwners is required if (!value.beneficialOwners.addedExistingBeneficialOwners) { ctx.addIssue({ code: 'custom', path: ['beneficialOwners', 'addedExistingBeneficialOwners'], message: 'Field.errorRequired', }) return // stop further validation } const ownersLength = value.beneficialOwners.addedExistingBeneficialOwners.length const path = ['beneficialOwners', 'addedExistingBeneficialOwners'] // Validate array length matches count exactly if (ownersLength < count) { ctx.addIssue({ code: 'custom', path, message: 'IterateArray.errorMinItems', messageValues: { minItems: count, }, }) } if (ownersLength > count) { ctx.addIssue({ code: 'custom', path, message: 'IterateArray.errorMaxItems', messageValues: { maxItems: count, }, }) } // Validate that each owner has a name (required) value.beneficialOwners.addedExistingBeneficialOwners.forEach( (owner, index) => { if (!owner.name) { ctx.addIssue({ code: 'custom', path: [ 'beneficialOwners', 'addedExistingBeneficialOwners', index, 'name', ], message: 'Field.errorRequired', }) } } ) } // Validate array length sync for all count values (including 0) if ( value.beneficialOwners?.addedExistingBeneficialOwners && Array.isArray(value.beneficialOwners.addedExistingBeneficialOwners) ) { const ownersLength = value.beneficialOwners.addedExistingBeneficialOwners.length const path = ['beneficialOwners', 'addedExistingBeneficialOwners'] // If count is not in the expected range, array length must not exceed count if (!counts.includes(count) && count >= 0 && ownersLength > count) { ctx.addIssue({ code: 'custom', path, message: 'IterateArray.errorMaxItems', messageValues: { maxItems: count, }, }) } } }) render( <Form.Handler schema={schema} defaultData={{ beneficialOwners: { addedExistingBeneficialOwners: undefined, }, }} > <Flex.Stack> <Form.Card> <Form.MainHeading>Membership</Form.MainHeading> <Field.Number path="/members/numberOfMembers" label="Number of members (1-3)" width="small" startWith={-1} showStepControls /> </Form.Card> <Form.Card> <Form.SubHeading>Beneficial owners</Form.SubHeading> <Iterate.Array path="/beneficialOwners/addedExistingBeneficialOwners" animate > <Section innerSpace={{ top: 'small', bottom: 'small', }} bottom backgroundColor="lavender" > <Field.String itemPath="/name" label="Owner name {itemNo}" /> <Iterate.RemoveButton /> </Section> </Iterate.Array> <Iterate.PushButton path="/beneficialOwners/addedExistingBeneficialOwners" pushValue={{}} text="Add beneficiary" /> </Form.Card> <Form.SubmitButton text="Show errors" /> <Tools.Log label="Form data" /> <Tools.Errors label="Errors" /> </Flex.Stack> </Form.Handler> ) ```