Performance
Define schemas once
The single most impactful change: define schemas at the top level so they are created once and reused on every call.
// wrong: schema created on every call
void validateUser(Map<String, dynamic> data) {
final schema = z.object({
'email': z.string().email(),
'age': z.integer(),
});
schema.parse(data);
}
// correct: schema defined once, reused on every call
final userSchema = z.object({
'email': z.string().email(),
'age': z.integer(),
});
void validateUser(Map<String, dynamic> data) {
userSchema.parse(data);
}
Every call to z.object({...}) constructs new schema instances. For schemas called in tight loops, in widget build() methods, or in request handlers, this cost accumulates. Move schema definitions to the top level (or to a static field).
// wrong: schema rebuilt on every widget rebuild
@override
Widget build(BuildContext context) {
final schema = z.object({'name': z.string(), 'email': z.string().email()});
final result = schema.safeParse(userData);
// ...
}
// correct: schema defined once at top level
final _userProfileSchema = z.object({
'name': z.string(),
'email': z.string().email(),
});
@override
Widget build(BuildContext context) {
final result = _userProfileSchema.safeParse(userData);
// ...
}
Batch array validation
To validate a list of objects, pass the list to a single z.array() schema rather than looping:
// less efficient: one schema invocation per item
final users = <Map<String, dynamic>>[];
for (final raw in jsonList) {
final result = userSchema.safeParse(raw);
if (result.isSuccess) users.add(result.value);
}
// more efficient: one schema invocation for the whole list
final listSchema = z.array(userSchema);
final result = listSchema.safeParse(jsonList);
if (result.isSuccess) {
final users = result.value; // List<Map<String, dynamic>>
}
z.array() validates every element and collects all issues in a single pass, including each element's index in the error path.
Minimise deeply nested schemas
Each nesting level adds a recursive traversal on every parse call. Flatten the data model where the domain permits:
// deep nesting: each level adds a parse call
final schema = z.object({
'address': z.object({
'location': z.object({
'coordinates': z.object({
'lat': z.double(),
'lng': z.double(),
}),
}),
}),
});
// flat alternative when nesting is not required
final schema = z.object({
'addressLat': z.double(),
'addressLng': z.double(),
});
When nesting is required, extract deeply nested schemas into named constants so they are not re-instantiated:
final coordinatesSchema = z.object({
'lat': z.double(),
'lng': z.double(),
});
final locationSchema = z.object({
'coordinates': coordinatesSchema,
});
final addressSchema = z.object({
'location': locationSchema,
});
Prefer extension types over classes for validated maps
Extension types add zero runtime overhead. Wrapping a validated map in an extension type costs nothing compared to constructing a class instance:
// extension type: zero extra allocation
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
}
final result = userSchema.safeParse(data);
if (result.isSuccess) {
final user = User(result.value); // wraps the existing map, no new object
}
// class: one new object allocated per instance
class User {
final String email;
User(this.email);
}
final result = userSchema.safeParse(data);
if (result.isSuccess) {
final user = User(result.value['email'] as String); // new allocation
}
For code paths that create large numbers of typed objects, extension types reduce GC pressure. For a small number of objects the difference is negligible. See Extension Types vs Classes.
Minimize async validation
Every refineAsync or safeParseAsync call introduces an async hop and potential I/O latency. Apply these rules:
Validate synchronously first. Run all synchronous constraints before any async check. If the synchronous checks fail, the async check is never reached.
Batch async checks. Instead of one async refinement per field, combine all I/O into a single call:
// slow: two separate async operations
final schema = z.object({
'username': z.string().refineAsync(
(s) async => checkUsernameAvailable(s),
message: 'Username taken.',
),
'email': z.string().email().refineAsync(
(e) async => checkEmailAvailable(e),
message: 'Email taken.',
),
});
// faster: one async operation for both checks
final schema = z.object({
'username': z.string(),
'email': z.string().email(),
}).refineAsync(
(data) async {
return checkAvailability(
username: data['username'] as String,
email: data['email'] as String,
);
},
message: 'Username or email already taken.',
);
Validate asynchronously only at the boundary. Parse data synchronously inside your domain logic and reserve async validation for the entry point (form submit, API handler).
Optimize regular expressions
Complex regex patterns with lookaheads are expensive. Splitting them into multiple simple checks is often faster and produces better error messages:
// complex: one pattern with lookaheads
final schema = z.string().regex(
RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$'),
message: 'Weak password.',
);
// simpler: individual constraints, each with its own message
final schema = z.string()
.min(8)
.superRefine((s, ctx) {
final issues = <ZemaIssue>[];
if (!s.contains(RegExp(r'[a-z]'))) issues.add(ZemaIssue(code: 'missing_lowercase', message: 'Needs a lowercase letter.'));
if (!s.contains(RegExp(r'[A-Z]'))) issues.add(ZemaIssue(code: 'missing_uppercase', message: 'Needs an uppercase letter.'));
if (!s.contains(RegExp(r'[0-9]'))) issues.add(ZemaIssue(code: 'missing_digit', message: 'Needs a digit.'));
if (!s.contains(RegExp(r'[@$!%*?&]'))) issues.add(ZemaIssue(code: 'missing_special', message: 'Needs a special character.'));
return issues.isEmpty ? null : issues;
});
Compile regex patterns once and reuse them if the same pattern is applied many times:
// reusable compiled patterns
final _emailDomainRe = RegExp(r'@([\w-]+\.)+[\w-]{2,}$');
final _alphanumericRe = RegExp(r'^[a-zA-Z0-9]+$');
final usernameSchema = z.string().regex(_alphanumericRe);
Memoization
Cache validation results for data that is validated repeatedly with the same schema. Use safeParse() (which never throws) to store results:
final _cache = <String, ZemaResult<Map<String, dynamic>>>{};
ZemaResult<Map<String, dynamic>> validatedUser(Map<String, dynamic> data) {
final key = data.toString(); // simple cache key
return _cache.putIfAbsent(key, () => userSchema.safeParse(data));
}
This is only applicable when:
- The same data is validated multiple times (e.g. repeated reads from a local store).
- The data is immutable after creation.
- The cache is bounded or cleared when data changes.
For unbounded input data this trades memory for CPU — profile before applying it.
Profiling
Measure before optimising. Use Stopwatch to isolate the schema itself:
import 'dart:developer';
void benchmarkSchema() {
final data = {'email': 'alice@example.com', 'age': 30};
// warmup: let the JIT compile the hot path
for (var i = 0; i < 100; i++) {
userSchema.safeParse(data);
}
// measure
final sw = Stopwatch()..start();
Timeline.startSync('zema.safeParse');
for (var i = 0; i < 10000; i++) {
userSchema.safeParse(data);
}
Timeline.finishSync();
sw.stop();
print('10,000 parses: ${sw.elapsedMilliseconds}ms');
print('Per parse: ${sw.elapsedMicroseconds / 10000}μs');
}
Open Flutter DevTools, go to Performance, and look for zema.safeParse spans to see where time is spent.
When to optimise
Validation is fast by default. Optimise when profiling shows validation is a bottleneck, not before.
Situations that may warrant optimisation:
- Validating thousands of objects per second (batch import, streaming API).
- Validation running on every animation frame (60 fps).
- Constrained devices (low-end mobile, embedded).
Situations that do not warrant optimisation:
- One-time startup validation.
- Validation on rare user actions (form submit, settings save).
- Validation time that is a small fraction of total request time.
Summary
| Technique | When to apply |
|---|---|
| Define schemas at top level | Always: the most impactful change |
Use z.array() for lists | Whenever validating a collection of items |
| Flatten unnecessary nesting | When data model allows it |
| Extract nested schemas to constants | When deep nesting is required |
| Extension types instead of classes | High-volume typed access to validated maps |
| Batch async checks | When multiple async refinements exist |
| Reuse compiled regex | When the same pattern is used in many places |
| Memoize results | Only when the same immutable data is validated repeatedly |
Next steps
- Extension Types vs Classes: allocation costs and when each is appropriate
- Async Validation:
safeParseAsync()andrefineAsync() - Custom Validators:
.superRefine()for multi-rule validation