Description
A FormzInput can only ever surface an error that its own validator produced — error is defined as validator(value) (lib/formz.dart:104). That leaves nowhere to put an error that originates outside the input:
- a server rejection, e.g. "this email is already taken"
- a cross-field rule, e.g. a password confirmation that does not match
- the result of an async check
Today the workaround is to hold that error in a separate field on the form state and reconcile it with displayError by hand at the widget layer, which means isValid lies: the form reports itself valid while the UI is showing an error.
Flutter hit the same problem and solved it in 3.24 with FormField.forceErrorText (flutter/flutter#132903), which puts a field into an error state without running its validator.
This came out of the documentation review for #122. That issue is about FormState.validateGranularly() (flutter/flutter#135578) and is already handled by #146 — this is the one remaining Flutter form API since 3.22 that has no formz equivalent and would need new library surface. Everything else Flutter has added (AutovalidateMode.onUnfocus, AutovalidateMode.onUserInteractionIfError, FormField.onReset, FormField.errorBuilder, FormState.clearError()) is widget-layer behavior that does not belong in a pure-Dart package.
Requirements
Additional Context
Proposed design: a mixin
This follows the FormzInputErrorCacheMixin precedent (lib/formz.dart:136-144) — opt-in, const-friendly, and no impact on FormzInput's core.
/// Mixin for [FormzInput] that allows an error to be supplied from outside the
/// input, such as from a server response or a cross-field rule.
///
/// The [forcedError] takes precedence over the result of [validator].
mixin FormzInputForcedErrorMixin<T, E> on FormzInput<T, E> {
/// An error supplied from outside the input.
E? get forcedError;
@override
E? get error => forcedError ?? super.error;
@override
bool get isValid => error == null;
@override
E? get displayError => forcedError ?? super.displayError;
}
Usage — the subclass declares forcedError and forwards it through a const constructor, the same way it already forwards value:
enum EmailError { empty, invalid, alreadyTaken }
class Email extends FormzInput<String, EmailError>
with FormzInputForcedErrorMixin<String, EmailError> {
const Email.pure({String value = '', this.forcedError}) : super.pure(value);
const Email.dirty({String value = '', this.forcedError}) : super.dirty(value);
@override
final EmailError? forcedError;
@override
EmailError? validator(String value) {
return value.isEmpty ? EmailError.empty : null;
}
}
// After the server rejects the submission:
state = state.copyWith(
email: Email.dirty(
value: state.email.value,
forcedError: EmailError.alreadyTaken,
),
);
Alternative considered: an optional named parameter
Adding {E? forcedError} to FormzInput.pure and FormzInput.dirty is closer to Flutter's shape, but every subclass still has to forward the parameter to expose it, and it pulls forcedError into hashCode, operator ==, and toString (lib/formz.dart:114-130) for everyone, whether they use it or not.
Open question to settle before implementation
Should a forced error surface through displayError on a pure input?
Flutter's forceErrorText shows regardless of user interaction, which argues yes — and the snippet above reflects that. But formz's displayError contract is currently "only once the input is dirty" (lib/formz.dart:108), so the two readings conflict. Worth deciding deliberately rather than falling out of the implementation.
Related
Description
A
FormzInputcan only ever surface an error that its ownvalidatorproduced —erroris defined asvalidator(value)(lib/formz.dart:104). That leaves nowhere to put an error that originates outside the input:Today the workaround is to hold that error in a separate field on the form state and reconcile it with
displayErrorby hand at the widget layer, which meansisValidlies: the form reports itself valid while the UI is showing an error.Flutter hit the same problem and solved it in 3.24 with
FormField.forceErrorText(flutter/flutter#132903), which puts a field into an error state without running itsvalidator.This came out of the documentation review for #122. That issue is about
FormState.validateGranularly()(flutter/flutter#135578) and is already handled by #146 — this is the one remaining Flutter form API since 3.22 that has no formz equivalent and would need new library surface. Everything else Flutter has added (AutovalidateMode.onUnfocus,AutovalidateMode.onUserInteractionIfError,FormField.onReset,FormField.errorBuilder,FormState.clearError()) is widget-layer behavior that does not belong in a pure-Dart package.Requirements
validator(value).isValid,isNotValid,error, anddisplayErrorall account for a forced error.FormzInputsubclasses.Formz.validateandFormzMixin.isValidreflect forced errors, since they delegate toisValid.FormzInputErrorCacheMixin.Additional Context
Proposed design: a mixin
This follows the
FormzInputErrorCacheMixinprecedent (lib/formz.dart:136-144) — opt-in, const-friendly, and no impact onFormzInput's core.Usage — the subclass declares
forcedErrorand forwards it through a const constructor, the same way it already forwardsvalue:Alternative considered: an optional named parameter
Adding
{E? forcedError}toFormzInput.pureandFormzInput.dirtyis closer to Flutter's shape, but every subclass still has to forward the parameter to expose it, and it pullsforcedErrorintohashCode,operator ==, andtoString(lib/formz.dart:114-130) for everyone, whether they use it or not.Open question to settle before implementation
Should a forced error surface through
displayErroron a pure input?Flutter's
forceErrorTextshows regardless of user interaction, which argues yes — and the snippet above reflects that. But formz'sdisplayErrorcontract is currently "only once the input is dirty" (lib/formz.dart:108), so the two readings conflict. Worth deciding deliberately rather than falling out of the implementation.Related
Futurevalidators. A forced error is a natural place to land an async result.