Skip to content
Haydar Demir

Writing6 min read

Mastering Form Validation in Flutter

Tired of writing a separate validator for every form field? The solution is just a few lines away!

A team reviewing a large registration form
Created by ChatGPT

Hello everyone!

Collecting user input in mobile applications is one of the most common requirements we encounter in almost every project. To handle this need, we often rely on form elements. In Flutter, when we talk about forms, components like TextFormField and DatePicker usually come to mind. However, one of the most critical aspects of form fields is the validation process. Typically, developers use inline or static functions assigned to the validator parameter.

But what if we could build a more modular, testable, and OOP-friendly structure instead of writing similar validation logic repeatedly for every screen and field?

In this article, I’ll walk you through how to build a custom form validation architecture in Flutter, how to manage different validators under a unified system, and how to make this structure easily extensible. Let’s dive in!

🧱 Basic implementation

Getting started with form validation in Flutter is fairly straightforward. The TextFormField widget provides a validator parameter, which accepts a function that can return an error message when the input is invalid.

Below is a simple example of email validation. It prompts the user to enter an email address and checks for both emptiness and valid format:

TextFormField(
  decoration: InputDecoration(
    labelText: 'Email',
  ),
  validator: (value) {
    if (value == null || value.isEmpty) {
      return 'Please enter an email address';
    }
    if (!isValidEmail(value)) {
      return 'Please enter a valid email address';
    }
    return null; // Return null for no validation errors
  },
)

Here, two basic checks are performed:

  • Is the input empty? → If so, an error message like ‘Please enter an email address’ is shown.
  • Is the email format valid? → If not, a second error message is returned.

This approach may be sufficient for small projects. However, in larger and scalable applications where multiple TextFormFields are used, writing individual validator functions like this for each field leads to code duplication and makes maintenance more difficult.

Advanced form validation

AppValidator

Below, you can see the AppValidator class and its related structures, which lay the foundation for a customizable and extensible form validation system:

final class AppValidator {
  AppValidator._();

  /// Applies all validation rules in the [validations] list sequentially.
  /// If any validation returns an error, that error message is displayed.
  static FormFieldValidator<T> apply<T>(
    BuildContext context,
    List<AppValidation<T>> validations,
  ) {
    return (T? value) {
      for (final validation in validations) {
        final error = validation.validate(context, value);
        if (error != null) return error;
      }
      return null; // Returns null if there's no error, meaning the validation was successful.
    };
  }
}

Thanks to this structure, adding a validator to a TextFormField becomes much cleaner. All you need to do is define the rules by implementing the AppValidation interface and pass them to the AppValidator.apply function.

For example, you can define a helper extension like the one below to display all validation messages in a consistent format:

extension FormStringExtension on String {
  String get xGetFormString => '* $this';
}

AppValidation interface

Each validation rule should extend the AppValidation abstract class. This class is defined as follows:

abstract class AppValidation<T> {
  /// Validates the given [value].
  /// Returns an error message if the validation fails.
  /// Returns `null` if the validation succeeds.
  String? validate(BuildContext context, T? value);
}

With this architecture, you can define custom rules such as RequiredValidation, EmailValidation, or PasswordConfirmValidation, and reuse them across different fields as needed.

Here are the advantages of this structure:

  • Reusability: The same validation rule can be used across multiple form fields.
  • Extensibility: New validation rules can be added easily.
  • Testability: Each validator can be tested independently.
  • Readability: Form fields become cleaner as validation logic is separated.

🧪 Example

Now, let’s take a look at some example validator classes that demonstrate how the AppValidation structure we defined earlier can be implemented in practice. These classes highlight how simple it is to build a readable and reusable validator architecture.

EmptyValidation

This class checks whether a field is empty. A custom error message can be provided via the message parameter; if not provided, a default translated message is used.

final class EmptyValidation extends AppValidation<String> {
  EmptyValidation({
    this.message,
  });

  final String? message;

  @override
  String? validate(BuildContext context, String? value) {
    if (value != null) {
      if (value.trim().isEmpty) {
        return message ??
            AppTranslations.formValidation.validation.xGetFormString;
      }
    } else {
      return message ??
          AppTranslations.formValidation.validation.xGetFormString;
    }
    return null;
  }
}

This class can be used as a basic validator for any type of TextFormField.

EmailValidation

Email validation is a common requirement in almost every project. The EmailValidation class only returns an error if the email field is not empty and the format is invalid.

final class EmailValidation extends AppValidation<String> {
  EmailValidation();

  @override
  String? validate(BuildContext context, String? value) {
    if (value == null || value.isEmpty) return null;

    if (!App.regexp.email.hasMatch(value)) {
      return AppTranslations.formValidation.invalidMail.xGetFormString;
    } else {
      return null;
    }
  }
}

Note: The email format is validated using App.regexp.email. If the field is empty, no error message is returned—this responsibility is delegated to EmptyValidation.

PasswordConfirmValidation

This class checks whether the entered password matches the confirm password field. The original password is passed as a parameter, and the comparison is made against that value.

final class PasswordConfirmValidation extends AppValidation<String> {
  PasswordConfirmValidation({required this.password});

  final String password;

  @override
  String? validate(BuildContext context, String? value) {
    if (value == null || value.isEmpty) return null;

    if (password.trim() != value.trim()) {
      return AppTranslations.formValidation.passwordsNotMatch.xGetFormString;
    } else {
      return null;
    }
  }
}

This validator is typically used in fields like ‘Confirm Password’ and works in conjunction with the main password field.

EmailFormField

final class EmailFormField extends StatelessWidget {
  const EmailFormField({
    super.key,
    required this.controller,
    this.labelText = 'Email',
  });

  final TextEditingController controller;
  final String labelText;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      decoration: InputDecoration(
        labelText: labelText,
      ),
      keyboardType: TextInputType.emailAddress,
      autofillHints: const [AutofillHints.email],
      validator: AppValidator.apply<String>(
        context,
        [
          EmptyValidation(),
          EmailValidation(),
        ],
      ),
    );
  }
}

Usage:

You can easily use it like this within a form:

final emailController = TextEditingController();

Form(
  key: _formKey,
  child: Column(
    children: [
      EmailFormField(controller: emailController),
      ElevatedButton(
        onPressed: () {
          if (_formKey.currentState?.validate() ?? false) {
            // Valid email input
          }
        },
        child: const Text('Submit'),
      ),
    ],
  ),
);

Conclusion

Thanks to the structure we walked through in this article, you don’t have to write separate validators for every single form field anymore. You can manage all your common validation rules in one place and easily add new ones as needed. Plus, it’s super easy to grow this setup together with your team.

Remember clean code doesn’t just make your life easier as a developer, it also keeps your app easier to maintain in the long run.

A smiling team waving behind an approved registration form
Created by ChatGPT

Related writing

Type to search