Validation Rules
Ack provides a rich set of built-in validation rules (constraints) that you can chain onto schema types to enforce specific requirements. Built-in constraints provide default error messages. For custom error messages, use custom constraints (see Custom Validation Guide).
nullable()
Allows the value to be null in addition to the type's constraints.
final optionalName = Ack.string().nullable();
final optionalAge = Ack.integer().nullable();
constrain(Constraint<T> constraint, {String? message})
Applies a custom Constraint<T> that also implements Validator<T>. See the Custom Validation guide.
String Constraints
Apply these to Ack.string() schemas.
Exact Length
To ensure a string has exactly a specific length, combine minLength() and maxLength():
// String must be exactly 10 characters
Ack.string().minLength(10).maxLength(10)
matches(String pattern, {String? example, String? message})
Requires the string to match a regular expression pattern.
Important: Patterns are NOT automatically anchored. The pattern will match if found anywhere in the string (substring matching). To require full-string matching, explicitly add anchors: ^...$
// Simple alphanumeric pattern (full-string match with anchors)
Ack.string().matches(r'^[a-zA-Z0-9]+$')
// UUID pattern (full-string match with anchors)
Ack.string()
.matches(r'^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$')
// Without anchors - matches substring (usually not what you want!)
Ack.string().matches(r'[0-9]+') // Matches "abc123xyz" ⚠️
contains(String pattern, {String? example, String? message})
Requires pattern to appear somewhere in the string.
// Password must contain at least one uppercase letter
Ack.string().contains(r'[A-Z]')
// Password must contain at least one digit
Ack.string().contains(r'[0-9]')
enumString(List<String> allowedValues)
Requires the value to be one of allowedValues. Use this only for ad-hoc string lists. When a Dart enum exists, prefer Ack.enumValues(MyEnum.values) for type-safe validation.
Ack.enumString(['active', 'inactive', 'pending'])
startsWith(String prefix)
Requires the string to start with prefix.
Ack.string().startsWith('https://')
ip({int? version})
Requires a valid IP address. Set version to restrict to IPv4 or IPv6.
Ack.string().ip() // Any IP (v4 or v6)
Ack.string().ip(version: 4) // IPv4 only
Ack.string().ip(version: 6) // IPv6 only
String Transformations
These methods transform the string value during parsing. They don't add validation constraints but modify the output value.
trim()
Removes leading and trailing whitespace from the string.
Ack.string().trim()
// " hello " → "hello"
Number Constraints
Apply these to Ack.integer(), Ack.double(), or Ack.number() schemas.
min(num limit)
Requires a value >= limit (inclusive).
Ack.integer().min(0) // >= 0
Ack.double().min(0.0) // >= 0.0
Ack.number().min(0) // >= 0
max(num limit)
Requires a value <= limit (inclusive).
Ack.integer().max(100) // <= 100
Ack.double().max(100.0) // <= 100.0
Ack.number().max(100) // <= 100
greaterThan(num limit)
Requires a value strictly > limit (exclusive).
Ack.integer().greaterThan(0) // > 0
Ack.double().greaterThan(0.0) // > 0.0
Ack.number().greaterThan(0) // > 0
lessThan(num limit)
Requires a value strictly < limit (exclusive).
Ack.integer().lessThan(100) // < 100
Ack.double().lessThan(100.0) // < 100.0
Ack.number().lessThan(100) // < 100
multipleOf(num factor)
Requires a value that is a multiple of factor.
Ack.integer().multipleOf(5) // Must be divisible by 5
Ack.double().multipleOf(0.5) // Use factors that avoid floating point rounding issues
Ack.number().multipleOf(0.5)
positive()
Requires a value greater than 0.
Ack.integer().positive() // > 0
Ack.double().positive() // > 0.0
Ack.number().positive() // > 0
negative()
Requires a value less than 0.
Ack.integer().negative() // < 0
Ack.double().negative() // < 0.0
Ack.number().negative() // < 0
safe() (Integer only)
Requires an integer within JavaScript's safe range (-2^53+1 to 2^53-1).
Ack.integer().safe()
finite()
Requires a finite number (rejects infinity and NaN). Ack.double() and
Ack.number() already enforce this by default; the method is available for
explicitness and API symmetry.
Ack.double().finite()
Ack.number().finite()
List Constraints
Apply these to Ack.list() schemas.
notEmpty()
Requires a non-empty list. Equivalent to minLength(1).
Ack.list(Ack.object({})).notEmpty()
unique()
Requires all items to be unique. Uses deep structural equality, so nested maps/lists are compared by value.
Ack.list(Ack.string()).unique()
Primitive Type Strictness
Primitive schemas (Ack.string(), Ack.integer(), Ack.double(), Ack.number(), Ack.boolean()) are strict: a value must already match the expected Dart runtime type. Mismatched inputs surface as a TypeMismatchError instead of being silently coerced.
Each schema maps to a specific runtime type:
| Schema | Accepted runtime type | Notes |
|---|---|---|
Ack.string() | String | rejects num, bool, etc. |
Ack.integer() | int | rejects double (even 42.0) |
Ack.double() | double | rejects int (even 42) |
Ack.number() | num | accepts both int and double |
Ack.boolean() | bool | rejects "true", 1, 0, etc. |
final stringSchema = Ack.string();
stringSchema.safeParse('hello'); // ✅ OK
stringSchema.safeParse(123); // ❌ FAIL: TypeMismatchError
stringSchema.safeParse(true); // ❌ FAIL: TypeMismatchError
final intSchema = Ack.integer();
intSchema.safeParse(42); // ✅ OK
intSchema.safeParse('42'); // ❌ FAIL: TypeMismatchError
intSchema.safeParse(42.0); // ❌ FAIL: TypeMismatchError (double is not int)
final doubleSchema = Ack.double();
doubleSchema.safeParse(3.14); // ✅ OK
doubleSchema.safeParse(42); // ❌ FAIL: TypeMismatchError (int is not double)
final numberSchema = Ack.number();
numberSchema.safeParse(42); // ✅ OK (int is num)
numberSchema.safeParse(3.14); // ✅ OK (double is num)
numberSchema.safeParse('42'); // ❌ FAIL: TypeMismatchError
Because Ack.integer() and Ack.double() do not overlap, use Ack.number() when a field may legitimately be either an int or a double. Reach for transform/codec only when the boundary value isn't already a num (for example, a numeric string).
This strictness makes anyOf and discriminated unions reliable — they can distinguish, for example, the string "42" from the integer 42 without configuration:
final stringOrNumber = Ack.anyOf([
Ack.string(),
Ack.integer(),
]);
stringOrNumber.safeParse('42'); // ✅ Matches string branch
stringOrNumber.safeParse(42); // ✅ Matches integer branch
Converting Boundary Types
When your boundary payload uses a different shape from your runtime model (for example, ISO strings → DateTime, or "true"/"false" → bool), express the conversion explicitly with transform or a codec:
// Boundary "true"/"false" string → runtime bool
final boolFromString = Ack.string()
.enumString(['true', 'false'])
.transform((s) => s == 'true');
boolFromString.safeParse('true'); // ✅ runtime value: true
boolFromString.safeParse('false'); // ✅ runtime value: false
boolFromString.safeParse(true); // ❌ FAIL: string schema rejects bool
Use schema.codec<R>(decode: ..., encode: ...) when you also need a reversible encode path back to the boundary type.
Combining Constraints
You can chain multiple constraints together. Ack evaluates them in the order you apply them.
final usernameSchema = Ack.string()
.minLength(3) // First: check min length
.maxLength(20) // Second: check max length
.matches(r'[a-z0-9_]+') // Third: check pattern (lowercase alphanumeric/underscore)
.notEmpty(); // Redundant if minLength(>0) is used, but illustrates chaining
final quantitySchema = Ack.integer()
.min(1) // Must be at least 1
.max(100) // Must be at most 100
.multipleOf(1); // Must be an integer (redundant for Ack.integer)
Next Steps
Now that you understand validation rules, explore these related topics:
- Error Handling: Learn how to handle and display validation errors
- Custom Validation: Create custom constraints and refinement logic
- Schema Types: Explore all available schema types and their operations
- Common Recipes: See practical validation patterns and solutions
- Flutter Forms: Integrate validation with Flutter form widgets