Skip to content

Custom validators ​

Coming in 0.2.0 (in review)

This page describes the validator registry on the form-layout-editor-7b branch, which is in review and not yet on main. Names may still change before it merges. On 0.1, see Validation for what is checked today.

In 0.2.0 every validation type is handled by a validator kind. The kind owns everything about that type: which fields it applies to, its default settings and message, its check, how its settings are saved, what makes one unusable, and the settings the designer edits. Built-in and app kinds are registered the same way.

The kinds ​

dart
abstract class FormValidatorKind<V extends FormValidation> {
  String get typeId;
  String get title;
  String get category;          // heading in the Add validator menu
  String? get description;
  String? get hint;
  bool get repeatable;          // may a field carry more than one?
  bool get checksEmpty;         // only "required" runs on an empty value

  bool appliesTo(FormFieldDefinition field);
  V create(FormFieldDefinition field);
  String defaultMessage(V rule, FormFieldDefinition field);
  String? check(V rule, Object? value, FormValidationContext context); // null = pass
  Map<String, Object?> encode(V rule);
  V decode(Map<String, Object?> settings, {required String? id, required String? message, required bool warning});
  String? designIssue(V rule, FormFieldDefinition field, Map<String, FormFieldDefinition> fields);
  List<KindSetting<V, Object?>> settingsFor(FormFieldDefinition field);
}

Kinds are registered in a FormKitExtension's validators list, and a kit built from the extensions holds them all. FormKitExtension.standard has one kind per built-in validation, in menu order. A kit throws an ArgumentError on a duplicate or blank typeId, naming both extensions. kit.validators (a FormKitValidators) reads them:

MemberMeaning
kindsEvery kind, in the kit's order: the order Add validator lists them.
applicableTo(field)The kinds whose appliesTo accepts the field.
categoriesThe kinds' categories, in the order they first appear.
check(rule, value, context)Runs the rule's kind. Empty values pass every kind except required; an unregistered kind passes.
designIssuesOf(definition)Every FormValidationDesignIssue(fieldKey, index, message, severity) in the form, such as a pattern that does not compile (an error) or a custom type with no registered kind (a warning).
encode(rule) / decode(json)JSON for one validation. A KitCodecException from decode names the setting at fault relative to the validation; kit.decode places it, as fields[3].validations[1].min.

Built-in categories (ValidatorCategories): General, Text, Number, Date & time, Choice & reference, Files, Across the form.

Writing a custom validator ​

An app validator is a CustomValidatorKind<C> with a typed settings class C. The settings are what the designer edits and what is stored in JSON; your check never sees a raw map.

This validator requires a site code to start with one of a configurable list of prefixes.

dart
import 'package:vyuh_form_types/vyuh_form_types.dart';

/// The site prefixes a code may start with.
final class SitePrefixes implements KindConfig {
  const SitePrefixes(this.prefixes);
  final List<String> prefixes;
}

final siteCode = CustomValidatorKind<SitePrefixes>(
  typeId: 'quality.site_code',
  title: 'Site code',
  category: 'Quality', // its own heading in the Add validator menu
  description: 'The code must start with one of the listed site prefixes.',
  appliesTo: (field) => field is TextFormField,
  defaultConfig: const SitePrefixes(['BLR']),
  encodeConfig: (config) => {'prefixes': config.prefixes},
  decodeConfig: (json, version) =>
      SitePrefixes((json['prefixes']! as List).cast<String>()),
  passes: (config, value, context) =>
      value is String && config.prefixes.any(value.startsWith),
  message: (config, field) =>
      '${field.label} must start with ${config.prefixes.join(', ')}.',
  designIssue: (config) =>
      config.prefixes.isEmpty ? 'Add at least one site prefix.' : null,
  settings: [
    CustomKindSetting<SitePrefixes, List<String>>(
      id: 'prefixes',
      label: 'Site prefixes',
      editor: const StringListSetting(suggestions: ['BLR', 'HYD', 'PUN']),
      read: (config) => config.prefixes,
      write: (config, prefixes) => SitePrefixes(prefixes),
    ),
  ],
);

CustomValidatorKind parameters ​

ParameterTypeMeaning
typeIdStringSaved with each validation. Namespace it, e.g. quality.site_code.
title, categoryStringShown in the Add validator menu.
appliesTobool Function(FormFieldDefinition)Which fields may use it.
defaultConfigCSettings of a new validation.
encodeConfig / decodeConfigMap<String, Object?> Function(C) / C Function(Map<String, Object?>, int version)The settings codec. version is the version the settings were saved with. Throw a FormatException from decodeConfig when the JSON is not settings of yours; the codec reports it as a KitCodecException at the validation's config.
passesbool Function(C, Object? value, FormValidationContext)The check. Runs only on non-empty values.
messageString Function(C, FormFieldDefinition)The default message. A validation's own message replaces it.
settingsList<CustomKindSetting<C, Object?>>What the designer edits.
designIssueString? Function(C)?Why settings are unusable, or null.
readsSet<String> Function(C)?The keys of other fields whose values passes reads from context.values. A live form re-checks the validator when one of them changes, besides its own field; leave it out when the check reads only the value.
description, hintString?Help in the menu and on the card.
versionint (default 1)The settings version this kind saves.
repeatablebool (default false)Whether a field may carry more than one.

Settings editors ​

CustomKindSetting<C, T>(id:, label:, editor:, read:, write:, enabledWhen:, help:, wide:) binds one value of type T in your settings to an editor on the validator card:

EditorValue type
WholeNumberSetting({min, emptyLabel})int?
DecimalSetting({emptyLabel, positive})num?
TextSetting({monospace, placeholder})String
ToggleSetting()bool
ChoiceSetting<E>(List<(E, String)> options)E, from a dropdown
OptionSetSetting<E>(List<(E, String)> options)List<E>, as toggle chips
StringListSetting({suggestions, placeholder})List<String>
DateSetting({includeTime, emptyLabel})DateTime?
DurationSetting({emptyLabel, allowNegative})Duration?
FieldSetting({required bool Function(FormFieldDefinition) accepts})String: another field's key

The check context ​

FormValidationContext gives passes more than the value:

MemberMeaning
fieldThe field being checked.
valuesThe values in scope: a repeating-group entry's values over the form's. Name the keys you read in reads.
fieldsEvery field by key.
nowThe moment of checking.
siblingsThis field's values in the other entries of its repeating group; null at form level.
labelOf(key)A field's label, for messages.
hostRejects(rule, value)Whether the host's FormValidationHost fails the value.

Checks only the host can answer, such as whether a record may be referenced, go through a FormValidationHost, whose callback returns true (passes), false (fails) or null (unknown, which passes locally so the host checks again on submission).

Registering it ​

A custom kind is registered once, in the app's extension, and the kit built from it is used wherever validations are checked, stored and designed.

dart
final quality = FormKitExtension(id: 'acme.quality', validators: [siteCode]);

// Checking and storage, in the app or on a server.
final kit = FormKit([FormKitExtension.standard, quality]);

// Designing: the controller's kit offers the kinds, in its order.
final controller = FormKitEditorController(
  definition,
  kit: FormKit([FormEditorStandard.extension, quality]),
);

Attach one in code (the designer does this from the Add validator menu):

dart
final code = TextFormField(
  key: 'site_code',
  label: 'Site code',
  validations: [
    CustomValidation('quality.site_code', config: const SitePrefixes(['BLR', 'HYD'])),
  ],
);

Run it outside a widget, or at fill time:

dart
final issues = FormValidationEngine.validate(definition, values, kit: kit);
if (issues.blocksSubmission) {
  // show the errors
}

final form = FormKitForm(
  definition,
  kit: kit,
  host: FormValidationHost((rule, value, context) => null),
  asyncHost: FormAsyncValidationHost((rule, value, context) async => null),
);
FormView(form: form);

A validation whose kind is not registered survives a JSON round trip exactly as it was loaded (its settings kept as an UnknownConfig, or none as NoConfig), as an unregistered condition or effect is; it is reported by designIssuesOf and not checked.

In the designer ​

The inspector's Validation tab (FormValidationTab) shows a caption such as "3 validators · run in order", an Add validator menu grouped by category, and one card per validation with its settings, severity and message. The menu offers only kinds whose appliesTo accepts the field and hides a non-repeatable kind the field already has. Cards reorder by dragging.

Each change is a controller command and one undo step:

CommandEffect
addValidation(fieldKey, rule)Appends a validation under a new id; refused for a second non-repeatable one.
replaceValidation(fieldKey, index, rule)Replaces the validation at index, keeping its id.
moveValidation(fieldKey, from, to)Reorders; rules apply a validation by its id, so they follow it.
removeValidation(fieldKey, index)Removes it; refused ("Used by 1 rule") while a rule applies it.
designIssuesEvery design issue in the current definition, errors and warnings (see Form status). Gate publishing on its errors.
dart
ListenableBuilder(
  listenable: controller,
  builder: (context, _) => FilledButton(
    onPressed: controller.designIssues.errors.isEmpty ? publish : null,
    child: const Text('Publish'),
  ),
)