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!

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.
