dovetail_form_validation 0.1.1 copy "dovetail_form_validation: ^0.1.1" to clipboard
dovetail_form_validation: ^0.1.1 copied to clipboard

Field validation for forms: seven composable rules, a builder to combine them, and failures that say which rule broke instead of a message the caller has to trust.

English · Português

dovetail_form_validation #

Seven form rules, a builder to compose them, and a failure that says which rule broke instead of a message the caller has to take on faith.

Pure Dart, no Flutter. 29 tests in under a second.

Why the failure is not text #

The React frontend this was ported from returned a translation key: "signup.errors.passwordShort". In Dart that does not work — the generated AppLocalizations exposes getters, not lookup by string, and there is no t('key').

The other two options do not serve either. Returning an already-resolved message forces you to rebuild the validator every time the language changes, and a validator built once in the controller keeps the old text forever. Returning a bool loses which of the field's two rules failed.

So the failure is a sealed type:

sealed class ValidationFailure { final String field; }

final class FieldIsRequired      extends ValidationFailure {}
final class FieldIsTooShort      extends ValidationFailure { minimum, actual }
final class FieldIsNotAnEmail    extends ValidationFailure {}
final class FieldIsNotDigits     extends ValidationFailure {}
final class FieldHasWrongLength  extends ValidationFailure { expected, actual }
final class FieldsDoNotMatch     extends ValidationFailure { other }
final class FieldIsNotADate      extends ValidationFailure {}
final class FieldIsTooOld        extends ValidationFailure { earliestYear }
final class FieldIsInTheFuture   extends ValidationFailure {}

What turns that into a sentence is the screen, with an exhaustive switch:

String messageFor(ValidationFailure failure, AppL10n t) => switch (failure) {
  FieldIsRequired()      => t.feedbackRequiredFields,
  FieldIsTooShort()      => t.signupErrorsPasswordShort,
  FieldIsNotAnEmail()    => t.emailInvalid,
  FieldIsNotDigits()     => t.signupErrorsInvalidPin,
  FieldHasWrongLength(:final int expected) => t.emailCodeRequired(expected),
  FieldsDoNotMatch()     => t.signupErrorsPasswordMismatch,
  FieldIsNotADate()      => t.signupErrorsInvalidBirthday,
  FieldIsTooOld()        => t.signupErrorsInvalidBirthday,
  FieldIsInTheFuture()   => t.signupErrorsInvalidBirthday,
};

No default. Adding a rule here breaks that switch at compile time, instead of falling through to empty text in front of someone. And the failure carries the numbers that produced it, so the message can be "3 characters short" rather than "too short".

The last three point at the same text on purpose: the old frontend gave one message for a bad date, and separating that is a product decision, not the port's. The failure already distinguishes the three, so the day that decision is made it costs nothing here.

Usage #

import 'package:dovetail/dovetail.dart'; // it comes through the barrel

final ValidationComposite password = ValidationComposite(<FieldValidation>[
  ...Field('password').min(8).rules,
  ...Field('passwordConfirmation').sameAs('password').rules,
]);

final ValidationFailure? error = password.validate(<String, String?>{
  'password': controller.text,
  'passwordConfirmation': confirmController.text,
});

validate stops at the first failure, in the order the rules were declared: a form that shows six messages at once is a form nobody reads to the end.

When the screen needs to mark every bad field at the same time, and still one message per field:

final Map<String, ValidationFailure> byField =
    password.failuresByField(values);

The input map is Map<String, String?> #

Absent and null read as empty. That is what a Flutter form really is — TextEditingController.text — and forcing the caller to normalise it is how a null passes as valid.

The seven rules #

builder refuses with
.required() FieldIsRequired — whitespace only counts as empty
.min(n) FieldIsTooShort — surrounding whitespace counts
.minTrimmed(n) FieldIsTooShort — surrounding whitespace does not count
.email() FieldIsNotAnEmail
.sameAs(other) FieldsDoNotMatch
.digits(n) FieldHasWrongLength or FieldIsNotDigits
.pastDate(year) FieldIsNotADate, FieldIsTooOld or FieldIsInTheFuture

min and minTrimmed were two classes in the old frontend; they became one with a flag, because the only difference between them is whether surrounding whitespace counts.

Three things that changed in the port, and why #

The email regexp came across character by character. Not because it is the best expression of an address — no regexp is — but because changing it would start refusing accounts that got in through the old app, and that is a product decision, not the port's.

digits separates the wrong length from the wrong character. The old frontend returned the same key for both. A six-digit PIN typed with five is a different mistake from one typed with a letter, and whoever typed it deserves to know which.

pastDate refuses the 30th of February. new Date('1990-02-30') does not fail: it rolls over to the 2nd of March. DateTime.tryParse does exactly the same. So that birthday was accepted, silently, as a different day from the one typed — and the test that proves it names the old bug. The defence is reformatting the date and comparing it against what came in.

What this package does not do #

It does not know AppL10n, does not know a widget, does not know the product. It says which rule broke and with what numbers; the sentence belongs to the screen, and the language to the l10n.

0
likes
150
points
136
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Field validation for forms: seven composable rules, a builder to combine them, and failures that say which rule broke instead of a message the caller has to trust.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

More

Packages that depend on dovetail_form_validation