Skip to content

feat: support externally-forced validation errors #166

Description

@marcossevilla

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

  • All CI/CD checks are passing.
  • There is no drop in the test coverage percentage.
  • A forced error takes precedence over validator(value).
  • isValid, isNotValid, error, and displayError all account for a forced error.
  • The behavior is opt-in and non-breaking for existing FormzInput subclasses.
  • Formz.validate and FormzMixin.isValid reflect forced errors, since they delegate to isValid.
  • The README gains a section covering it.
  • Unit tests cover a forced error on both pure and dirty inputs, and its interaction with FormzInputErrorCacheMixin.

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureA new feature or request

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions