trust_but_verify

A fluent, flexible, and typesafe validation library that supports async validators, type casting & transformations, custom error messages,and (optionally) fpdart types

Table of Contents

Features

  • Fluent API: Chain validation rules with a clean, readable syntax
  • Batch Validation: Validate multiple fields at once
  • Nullable Support: Specialized validators for handling optional fields
  • Type Casting & Transformation: Convert between types while validating (String to int, nullable to non-nullable, etc.)
  • Multiple Validation Modes: Support for both synchronous and asynchronous validation
  • Comprehensive Validators: Built-in validators for strings, numbers, nullable types, and easy creation of custom validation logic
  • Flutter Form Compatibility: Built-in support for Flutter form validation with asFormValidator() method
  • Error Handling: Comprehensive error system with specific error types for different validation scenarios
  • Optional Field Names: Use .trust('Email') with a name, or .trust() for generic messages
  • Custom Error Messages: Override error messages at verification time with lambdas
  • Direct Either/TaskEither Support: Start validation chains directly from fpdart's Either and TaskEither values. Optionally return validation result as an Either/TaskEither
  • Internationalization: Support for custom validation messages with type-safe interfaces and default English fallbacks

Getting Started

Add trust_but_verify to your pubspec.yaml:

dependencies:
  trust_but_verify: ^0.6.0
  fpdart: ^1.1.1 # Optional - for Either/TaskEither support

Import the library:

import 'package:trust_but_verify/trust_but_verify.dart';

Usage

Basic Single Field Validation

// Simple validation with exception handling
try {
  final validatedEmail = email
      .trust('Email')
      .isNotEmpty()
      .isEmail()
      .verify();
  print('Valid email: $validatedEmail');
} catch (e) {
  if (e is ValidationError) {
    print('Validation failed: ${e.message}');
  }
}

// Validation without field name (uses generic messages like "Value cannot be empty")
final result = email
    .trust()
    .isNotEmpty()
    .isEmail()
    .verify();

// Custom error message at verification time
final value = email
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .verify((fieldName) => 'Please enter a valid $fieldName');

// Functional validation with Either (from fpdart package)
final status = email
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .verifyEither()
    .fold(
      (error) => 'Validation failed: ${error.message}',
      (validEmail) => 'Valid email: $validEmail',
    );

Flutter Form Validation

class EmailForm extends StatefulWidget {
  @override
  _EmailFormState createState() => _EmailFormState();
}

class _EmailFormState extends State<EmailForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();

  // This is now a [FormFieldValidator<String?>] that can be used in a [FormField]
  //
  // [asFormValidator] causes the validation to return a [String?] instead of a [Either<ValidationError, String>]
  // This is useful for Flutter forms since they expect a [String?] to display the error message or [null] to signal success.
  String? _validateEmail(String? value) => value
        .trust('Email')
        .isNotNull()
        .isNotEmpty()
        .isEmail()
        .asFormValidator(); // Convenience method for Flutter forms (same as errorOrNull())
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            validator: _validateEmail,
            decoration: InputDecoration(labelText: 'Email'),
          ),
        ],
      ),
    );
  }
}

Multiple Field Validation

// Validate all fields and throw on first error
try {
  final values = [
    email.trust('Email').isNotEmpty().isEmail(),
    password.trust('Password').isNotEmpty().minLength(8),
    age.trust('Age').min(13).max(120),
  ].verify();
  // values contains [validatedEmail, validatedPassword, validatedAge]
} catch (e) {
  if (e is ValidationError) {
    print('Validation failed: ${e.message}');
  }
}

Asynchronous Validation

// Async validation with API call
final result = await email
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .toAsync()
    .tryMap((email) async {
      // Check if email exists in database
      final exists = await userService.emailExists(email);
      if (exists) {
        throw Exception('Email already registered');
      }
      return email;
    }, (fieldName) => '$fieldName already registered')
    .verify();

Support for fpdart Functional Types

While trust_but_verify is designed to be a general-purpose validation library for any Dart project, it provides first-class support for users of the fpdart functional programming library.

Flexible Verification

Every validation chain can be ended in different ways depending on your preferred error-handling style. This allows the library to fit seamlessly into both standard imperative Dart and pure functional architectures.

Method Result Type Benefit
.verify() T Standard Dart. Throws ValidationError on failure.
.verifyEither() Either<ValidationError, T> Pure functional. Returns Left on failure, Right on success.
.verifyTaskEither() TaskEither<ValidationError, T> Async functional. Returns a TaskEither for further chaining.
import 'package:fpdart/fpdart.dart';

// 1. Standard Dart (Throws on failure)
final email = 'test@example.com'.trust('Email').isEmail().verify();

// 2. Functional style with Either (Returns Left/Right)
final result = 'test@example.com'.trust('Email').isEmail().verifyEither();

// 3. Async functional style with TaskEither
final task = 'test@example.com'.trust('Email').isEmail().toAsync().verifyTaskEither();

Starting Chains from Functional Types

You can start validation chains directly from Either or TaskEither values, which is useful for integrating validation into existing functional flows.

// Start validation from an existing Either
final Either<String, String> input = Right('test@example.com');
final validated = input
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .verifyEither();

// Start validation from a TaskEither (propagates upstream failures)
final taskInput = TaskEither<String, String>.right('test@example.com');
final asyncValidated = await taskInput
    .trust('Email')
    .verifyEither();

Built-in Validators

String Validators

email.trust('Email')
    .isNotEmpty()           // Ensures field is not empty
    .isNotEmpty(allowWhitespace: true)  // Allows whitespace-only strings
    .isEmail()            // Validates email format
    .minLength(5)         // Minimum length
    .maxLength(100)       // Maximum length
    .isPattern(RegExp(r'^[a-z]+$'), 'lowercase letters only') // Custom regex
    .isUrl()              // Validates URL format
    .isPhone()            // Validates phone number format
    .contains('required') // Must contain substring
    .startsWith('https')  // Must start with prefix
    .endsWith('.com')     // Must end with suffix
    .alphanumeric()       // Only alphanumeric characters
    .lettersOnly()        // Only letters
    .digitsOnly()         // Only digits
    .isUuid()             // Valid UUID format
    .isCreditCard()       // Valid credit card number
    .isPostalCode()       // Valid postal code format
    .isIsoDate()          // Valid ISO date (YYYY-MM-DD)
    .isTime24Hour()       // Valid 24-hour time (HH:MM)
    .isOneOf(['active', 'inactive', 'pending']) // Must be one of specified values
    .isOneOf(['ACTIVE', 'INACTIVE'], caseInsensitive: true) // Case-insensitive comparison
    .isNoneOf(['admin', 'root', 'system']) // Must not be one of specified values
    .isNoneOf(['ADMIN', 'ROOT'], caseInsensitive: true) // Case-insensitive comparison
    .verify();

These validators provide a fluent, chainable API and automatically use the field name in error messages for better user experience.

Numeric Validators

age.trust('Age')
    .min(0)               // Minimum value
    .max(120)             // Maximum value
    .inRange(13, 65)      // Value within range
    .isPositive()         // Must be positive
    .isNonNegative()      // Must be non-negative
    .isInt()              // Must be an integer
    .isEven()             // Must be even
    .isOdd()              // Must be odd
    .isPowerOfTwo()       // Must be a power of 2
    .isPortNumber()       // Must be a valid port number (1-65535)
    .isWithinPercentage(target, 5.0) // Within 5% of target value
    .isOneOf([1, 2, 3, 4, 5]) // Must be one of specified values
    .isNoneOf([80, 443, 8080]) // Must not be one of specified values
    .verify();

Nullable Validators

optionalField.trust('Optional Field')
    .isNotNull()
    .verify();

Type Casting and Transformation Validators

Some validators not only validate but also transform the value type, enabling different validation chains:

// String to Integer transformation
final result = '123'
    .trust('Number String')
    .toInt()              // Converts String to int, enables numeric validators
    .min(100)             // Now we can use numeric validators
    .max(200)
    .isEven()
    .verify();

// Nullable to Non-nullable transformation
final result = (someNullableString as String?)
    .trust('Optional String')
    .isNotNull()          // Converts String? to String, enables string validators
    .isNotEmpty()           // Now we can use string validators
    .isEmail()
    .verify();

// Type validation with isType<T>()
// NOTE: isType<T>() works on Object? or more specific types.
// It does NOT work directly on dynamic types due to Dart limitations.
// If you have a dynamic value, cast it to Object? first.
final result = (someDynamicValue as Object?)
    .trust('Dynamic Field')
    .isType<int>()        // Validates type is int and returns SyncValidationStep<int>
    .min(10)              // Now we can use numeric validators
    .verify();

// Custom transformation with tryMap
final result = '2023-12-25'
    .trust('Date String')
    .tryMap(
      (value) => DateTime.parse(value),  // Converts String to DateTime
      (fieldName) => '$fieldName must be a valid date',
    )
    .verify();

These transformation validators are powerful because they allow you to:

  • Convert between types while validating
  • Chain different types of validators
  • Handle nullable to non-nullable conversions
  • Create custom type transformations with tryMap()
  • Type cast generic or unknown types safely with isType<T>()

Advanced Features

Custom Validation

// Custom validation with ensure()
final result = 'hello world'
    .trust('Custom String')
    .ensure(
      (value) => value.contains('world'),
      (fieldName) => '$fieldName must contain "world"',
    )
    .verifyEither();

// Custom transformation with tryMap()
final result = '123'
    .trust('Number String')
    .tryMap(
      (value) => int.tryParse(value) ?? throw Exception('Invalid number'),
      (fieldName) => '$fieldName must be a valid number',
    )
    .verifyEither();

Using the bind() Method

The bind() method allows you to chain validation steps by passing the current value to a function that returns an Either. This is useful for complex validation logic that requires multiple steps or conditional validation. Alternatively, you can use the pass() and fail() helper methods to create success and failure results without importing fpdart.

// Complex validation with bind()
final result = 'user@example.com'
    .trust('Email')
    .bind((email) {
      // Check if email is from allowed domains
      final allowedDomains = ['example.com', 'company.org'];
      final domain = email.split('@').last;

      if (!allowedDomains.contains(domain)) {
        return Left(ValidationError('Email', 'Email must be from an allowed domain'));
      }

      // Check if email is not too long
      if (email.length > 50) {
        return Left(ValidationError('Email', 'Email must be less than 50 characters'));
      }

      return Right(email);
    })
    .verifyEither();

// Conditional validation with bind()
final result = age
    .trust('Age')
    .bind((value) {
      if (value < 18) {
        return Left(ValidationError('Age', 'Must be at least 18 years old'));
      }

      if (value > 65) {
        return Left(ValidationError('Age', 'Must be under 65 years old'));
      }

      // Additional business logic
      if (value == 25) {
        return Left(ValidationError('Age', 'Age 25 is not allowed for this application'));
      }

      return Right(value);
    })
    .verifyEither();

Creating Custom Extensions

You can create custom extensions for specific types of ValidationStep to add domain-specific validators. Use the pass() and fail() helper methods to create success and failure results.

// Custom extension for String validation
extension CustomStringExtension on SyncValidationStep<String> {
  /// Validates that the string is a strong password
  SyncValidationStep<String> isStrongPassword() => bind((value) {
    if (value.length < 8) {
      return fail('$fieldName must be at least 8 characters long');
    }

    if (!RegExp(r'[A-Z]').hasMatch(value)) {
      return fail('$fieldName must contain at least one uppercase letter');
    }

    if (!RegExp(r'[a-z]').hasMatch(value)) {
      return fail('$fieldName must contain at least one lowercase letter');
    }

    if (!RegExp(r'[0-9]').hasMatch(value)) {
      return fail('$fieldName must contain at least one number');
    }

    if (!RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(value)) {
      return fail('$fieldName must contain at least one special character');
    }

    return pass(value);
  });
}

// Custom extension for numeric validation
extension CustomNumExtension<T extends num> on SyncValidationStep<T> {
  /// Validates that the number is a valid age for employment
  SyncValidationStep<T> isEmploymentAge() => bind((value) {
    if (value < 16) {
      return fail('$fieldName must be at least 16 years old for employment');
    }

    if (value > 70) {
      return fail('$fieldName must be under 70 years old for employment');
    }

    return pass(value);
  });

  /// Validates that the number is a valid percentage (0-100)
  SyncValidationStep<T> isPercentage() => bind((value) {
    if (value < 0 || value > 100) {
      return fail('$fieldName must be between 0 and 100');
    }

    return pass(value);
  });
}

// Usage of custom extensions
final passwordResult = 'MyP@ssw0rd'
    .trust('Password')
    .isStrongPassword()
    .verify();

final ageResult = 25
    .trust('Age')
    .isEmploymentAge()
    .verify();

Error Handling

trust_but_verify provides a comprehensive error handling system with specific error types for different validation scenarios. This allows for better debugging and more precise error handling in your applications.

Error Types

The library uses a hierarchical error system:

  • ValidationError: Base error class for all validation failures
  • StringValidationError: Specific errors for string validation (email, URL, pattern matching, etc.)
  • NumericValidationError: Specific errors for numeric validation (min/max, range, even/odd, etc.)
  • NullableValidationError: Specific errors for nullable field validation
  • Core validation errors: FieldInitializationError, AsyncFieldInitializationError, TryMapValidationError, CheckValidationError, BindValidationError

All error classes support copyWith({String? message}) for customizing error messages.

Basic Error Handling

// Get error message or null (useful for Flutter forms)
final error = email
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .errorOrNull();

if (error != null) {
  // Display error in UI
  print('Error: $error');
}

Advanced Error Handling

  // Handle specific error types and optionally change the error message
  final result = 'test@example.com'
      .trust('Email')
      .isNotEmpty()
      .isEmail()
      .verifyEither()
      .fold(
        (error) => switch (error) {
          EmptyStringValidationError _ => 'Email field is empty',
          InvalidEmailValidationError _ => 'Email format is invalid',
          StringValidationError e => 'Email transformation failed: $e',
          _ => 'Validation failed: ${error.message}',
        },
        (validEmail) => 'Valid email: $validEmail',
      );

  print(result);

Error with Stack Traces

All validation errors include optional stack traces for better debugging:

try {
  final result = email
      .trust('Email')
      .tryMap(
        (value) => throw Exception('Custom error'),
        (fieldName) => '$fieldName transformation failed',
      )
      .verify();
} catch (e) {
  if (e is TryMapValidationError) {
    print('Error: ${e.message}');
    print('Field: ${e.fieldName}');
    print('Stack trace: ${e.stackTrace}');
  }
}

Additional Examples

Form Data Object Validation (with fpdart Either type)

class UserRegistrationForm {
  final String email;
  final String password;
  final int age;
  final String? phone;

  UserRegistrationForm({
    required this.email,
    required this.password,
    required this.age,
    this.phone,
  });

  Either<ValidationError, UserRegistrationForm> validate() {
    return [
      email.trust('Email').isNotEmpty().isEmail(),
      password.trust('Password').isNotEmpty().minLength(8),
      age.trust('Age').min(13).max(120),
      if (phone != null) phone!.trust('Phone').isPhone(),
    ].verifyEither().map((_) => this);
  }
}

API Response Validation (with fpdart Either type)

Future<Either<ValidationError, User>> validateUserResponse(Map<String, dynamic> json) async {
  return await json['email']
      .toString()
      .trust('Email')
      .isNotEmpty()
      .isEmail()
      .toAsync()
      .tryMap((email) async {
        // Additional async validation
        final userExists = await userService.exists(email);
        if (!userExists) {
          throw Exception('User not found');
        }
        return User(email: email);
      }, (fieldName) => '$fieldName not found')
      .verifyTaskEither()
      .run();
}

Error Messages

The library provides descriptive error messages that include the field name and uses specific error types for better debugging:

  • "Email must be a valid email address" (InvalidEmailValidationError)
  • "Password must be at least 8 characters long" (InvalidLengthValidationError)
  • "Age must be between 13 and 120" (InvalidRangeValidationError)
  • "Phone must be a valid phone number" (InvalidPhoneValidationError)

When no field name is provided (using .trust()), generic messages are used:

  • "Value cannot be empty"
  • "Value must be a valid email address"

Internationalization

trust_but_verify supports custom validation messages through a type-safe internationalization system. You can override specific messages or provide complete custom implementations while maintaining default English fallbacks.

Basic Usage

import 'package:trust_but_verify/trust_but_verify.dart';

// Configure custom messages globally
ValidationStep.configureMessages(CustomValidationMessages());

// All validation operations will now use your custom messages
final result = email
    .trust('Email')
    .isNotEmpty()
    .isEmail()
    .verify();

Partial Override with Mixin

Override only the messages you want to customize while keeping the default English implementation for the rest:

// Use the ValidationMessagesMixin to override only the messages you want to customize
class CustomValidationMessages with ValidationMessagesMixin {
  @override
  String emptyField(String fieldName) => 'The $fieldName field cannot be empty';

  @override
  String invalidEmail(String fieldName) => 'Please enter a valid email address for $fieldName';

  // All other messages will use the default English implementation
}

// Configure the package to use your custom messages
ValidationStep.configureMessages(CustomValidationMessages());

Complete Custom Implementation

Implement all messages for a complete translation:

// Implement the ValidationMessages interface to provide your own complete translations
class SpanishValidationMessages implements ValidationMessages {
  @override
  String emptyField(String fieldName) => 'El campo $fieldName está vacío';

  @override
  String minLength(String fieldName, int length) =>
      '$fieldName debe tener al menos $length caracteres';

  @override
  String invalidEmail(String fieldName) =>
      '$fieldName debe ser una dirección de correo válida';

  // ... implement all other methods
}

// Configure the package to use Spanish messages
ValidationStep.configureMessages(SpanishValidationMessages());

Reset to Defaults

// Reset to default English messages
ValidationStep.resetMessages();

Type Safety

The internationalization system is fully type-safe:

  • Compile-time checking ensures all required methods are implemented
  • Method signatures include proper parameters for string interpolation
  • Default implementations provide English fallbacks for all messages
  • No possibility of missing translations

Example Implementation

See example/i18n_example.dart for a complete working example of the internationalization system.

Migration from fpvalidate

If you're upgrading from the previous fpvalidate package, here are the API changes:

Old API (fpvalidate) New API (trust_but_verify)
import 'package:fpvalidate/fpvalidate.dart' import 'package:trust_but_verify/trust_but_verify.dart'
.field('Name') .trust('Name') or .trust()
.validate() .verify() or .verify((fieldName) => 'Custom message')
.validateEither() .verifyEither()
.validateTaskEither() .verifyTaskEither()
.validateAsync() .verifyAsync()

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

trust_but_verify
A fluent validation library for Dart with support for transformation and sync/async validators.