Zema Logo
Advanced

Performance

Techniques for reducing validation overhead: schema reuse, nesting, batch validation, async, regex, and memoization.

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

TechniqueWhen to apply
Define schemas at top levelAlways: the most impactful change
Use z.array() for listsWhenever validating a collection of items
Flatten unnecessary nestingWhen data model allows it
Extract nested schemas to constantsWhen deep nesting is required
Extension types instead of classesHigh-volume typed access to validated maps
Batch async checksWhen multiple async refinements exist
Reuse compiled regexWhen the same pattern is used in many places
Memoize resultsOnly when the same immutable data is validated repeatedly

Next steps

Copyright © 2026