DEV Community

Cover image for Eskema: Composable, Ergonomic Runtime Data Validation for Dart Done Right!!
Keff
Keff

Posted on

Eskema: Composable, Ergonomic Runtime Data Validation for Dart Done Right!!

Every Dart project has that one file — you know the one.

The file full of if (value == null || value is! String || value.isEmpty) checks.

The file that starts out as “quick validation” and slowly mutates into a plate of spaghetti code that nobody dares to touch. 🍝

I’ve been there. In fact, that’s what pushed me to build Eskema.

Most existing solutions I found leaned on code generation, which meant I ended up with opinionated, hard-to-read boilerplate and mysterious generated classes. I wanted something else: a library that was declarative, ergonomic, and unopinionated. Something that makes it obvious what is being validated and where it’s happening, without sprinkling spaghetti across my codebase.

Eskema started as a tiny functional library a few years ago, but it has since grown up: now it has a solid class-based core, operator sugar, and a builder API. It’s still extremely simple to extend; adding a new validator or transformer is basically trivial, but it’s powerful enough to cover most real-world validation needs.



Why Eskema?

Key features of Eskema include:

  • Composable API: Validators are just Dart functions that take a value and return a Result. You can nest and combine them freely (with all(), or(), or the overloaded &/| operators). This makes validation logic highly composable and easy to reason about.

  • Rich built-in validators: Out of the box, Eskema has checks for types, numbers, strings, lists, maps, etc. For example, isString(), isInt(), isEmail(), listEach(), listIsOfLength(), and many more. Presence checks (isNotNull(), isNotEmpty(), isPresent()) and comparison checks (isGt(), isLte(), isIn(), etc.) are included too.

  • Operator sugar: Combine validators with & (AND) and | (OR), and invert with not(). For example, instead of all([isString(), isNotEmpty()]), you can simply write $isString & isNotEmpty(). You can also override error messages inline with the > operator, e.g. hasLength(5) > 'must be 5 chars'.

  • Optional vs Nullable semantics: By default, a key must be present and non-null. Use nullable(validator) to allow a field to be null, or optional(validator) to allow it to be missing entirely. This helps avoid the common confusion between “no value” and “null value.”

  • Builder API: If you prefer a more fluent, type-safe style, Eskema offers a builder API. For example:

  final userValidator = $map().schema({
    'id': $string().trim().toIntStrict().gt(0),
    'email': $string().trim().toLowerCase().email(),
    'age': $string().toIntStrict().gte(18).optional(),
  });
Enter fullscreen mode Exit fullscreen mode
  • No codegen – all runtime: Eskema validates plain Dart maps and values at runtime. No build steps, no generated classes.

  • Production-ready: Fully tested, documented, and with sensible error messages. Failures produce a structured list of expectations (message, field path, etc.), not just a bool.

Functional Validation Examples

Here’s a validator for a user object using the functional API:

final userSchema = eskema({
  'username': isString() & isNotEmpty(),
  'password': isString() & hasLength(8, 32),
  'email': isString() & isEmail(),
  'signupDate': optional(isDateTime()),
});

final result = userSchema.validate({
  'username': 'alice',
  'password': 'secret123',
  'email': 'alice@example.com',
  // signupDate omitted is OK (optional)
});

if (!result.isValid) {
  print(result.expectations);
}
Enter fullscreen mode Exit fullscreen mode

Each field maps to a validator. We combined checks with & instead of writing all([...]), and used optional(isDateTime()) to allow signupDate to be missing. The Result object has .isValid and .expectations with detailed error info.

If you only need to validate a single value:

final isNonEmptyStr = isString() & isNotEmpty();
print(isNonEmptyStr.isValid('hello'));          // true
print(isNonEmptyStr.validate('').isValid);      // false
Enter fullscreen mode Exit fullscreen mode

Writing custom validators is easy too:

Validator isEven = Validator((value) {
  return Result(
    isValid: value is int && value % 2 == 0,
    expected: 'even integer',
  );
});
Enter fullscreen mode Exit fullscreen mode

Before vs After Eskema

Let’s be honest: we’ve all written validation code like this at some point:

// Before: classic if/else soup
bool validateUser(Map<String, dynamic> user) {
  if (user['username'] == null || user['username'] is! String || user['username'].isEmpty) {
    return false;
  }

  if (user['password'] == null || user['password'] is! String) {
    return false;
  }
  if ((user['password'] as String).length < 8 || (user['password'] as String).length > 32) {
    return false;
  }

  if (user['email'] == null || user['email'] is! String) {
    return false;
  }
  final email = user['email'] as String;
  final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
  if (!emailRegex.hasMatch(email)) {
    return false;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Not only is this verbose, but it’s brittle: hard to extend, hard to read, and easy to get wrong.

Now here’s the same logic expressed with Eskema:

// After: Eskema
final userSchema = eskema({
  'username': isString() & isNotEmpty(),
  'password': isString() & hasLength(8, 32),
  'email': isString() & isEmail(),
});

final result = userSchema.validate({
  'username': 'alice',
  'password': 'secret123',
  'email': 'alice@example.com',
});

print(result.isValid); // true
Enter fullscreen mode Exit fullscreen mode

Clean, declarative, and composable. Instead of juggling if/else and regex checks, you just describe what you expect — and Eskema takes care of the rest.

Using the Builder

For those who like a fluent, IDE-friendly approach, Eskema’s builder API provides similar power with method chaining:

NOTE that everything is a validator, so you can combine and compose functional and builder validators.

final mapValidator = $map().schema({
  'id': $string().trim().toIntStrict().gt(0),
  'tags': $list().each($string()).lengthMin(1),
});

// Usage:
final res = mapValidator.validate({'id': '42', 'tags': ['dart', 'eskema']});
print(res.isValid); // true
Enter fullscreen mode Exit fullscreen mode

This yields the same result as manual validators, but in a fluent syntax. The advantage lies in ergonomics: IDE autocompletion, type safety, and the elimination of the need to import numerous free functions.

Ergonomics and Tips

  • Use the $ shortcuts: Many zero-arg validators have a cached alias. For example, $isString is a pre-built isString(). Looks clean and avoids re-allocating functions. Use isString() if you want to specify a custom error message.

  • Operator overloading: Get comfortable with &, | and not(). They make schemas terser and more logical to read.

  • Clear error messages: Use > to override messages, e.g. hasLength(5) > Expectation(...).

  • No boilerplate: Works out of the box for Flutter forms, API handlers, config files – anywhere you need validation.

  • Async support: Need async checks? Just use .validateAsync(). Eskema will promote validators to async as needed.

Conclusion

Eskema takes the headache out of runtime validation by providing a simple and flexible toolkit. It’s small in scope but big in composability. You can validate nested JSON, lists, or individual values with clear syntax and rich error reporting – all without codegen or ceremony.

If your app deals with dynamic data, give Eskema a try. A few ergonomic validators might just save you from a world of edge-case bugs – and maybe even a little coding despair.

👉 Give it a spin: Eskema on GitHub. Your future self (and your teammates) will thank you.”

Top comments (0)