dovetail_form_validation 0.1.1
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.