1

I am looking for a good way to validate freezed models. So far I came up with three approaches, which are shown in the snippet below.

@freezed
class Options with _$Options {
  Options._();

  factory Options._internal({required List<String> languages}) = _Options;

  // #1: validation in factory constructor
  factory Options({required List<String> languages}) {
    if (languages.isEmpty) {
      throw Exception('There must be at least one language.');
    }

    return Options._internal(languages: languages);
  }

  // #2: expose mutation methods with built-in validation
  Options changeLanguages(List<String> languages) {
    if (languages.isEmpty) {
      throw Exception('There must be at least one language.');
    }
    return copyWith(languages: languages);
  }

  // #3: validation using custom properties
  late final List<Exception> validationResult = <Exception>[
    if (languages.isEmpty) Exception('There must be at least one language.'),
  ];

  // #4: validation using a custom method
  void validate() {
    if (languages.isEmpty) {
      throw Exception('There must be at least one language.');
    }
  }
}

#1: Validation inside a factory constructor. Unfortunately, this only works for newly created objects and requires further changes for copyWith.

#2: Validation inside a mutation method. This could be used in addition to #1 to run validation after object creation, but still does not work for copyWith.

#3: Exposing a property with validation errors. So far, this is my favorite approach, even though it requires users of the model to explicitly look for errors.

#4: A variation of #3, which uses a throwing method instead of providing a list of errors.

What are your thoughts on this? Do you know any better approaches or is there a part of the package API, which I have overlooked?

kevlatus
  • 330
  • 1
  • 8

3 Answers3

0

Freezed added support for custom asserts in v0.12.0: https://pub.dev/packages/freezed#asserts. Applying these to your example results in the following:

@freezed
abstract class Options with _$Options {
  Options._();

  @Assert('languages.isNotEmpty', 'There must be at least one language.')
  factory Options({required List<String> languages}) = _Options;
}

However, this doesn't allow you to throw arbitrary exceptions, and asserts are only included in debug builds, not in profile/release builds.

Jonas Wanke
  • 399
  • 5
  • 11
  • Asserts would be great for debug-only purposes, but I am looking for a solution, which allows for throwing user-facing exceptions. Thanks though! – kevlatus Apr 05 '21 at 12:23
0

I would probably move the validation one step below. You can create a model LanguageList:

class LanguageList {
  LanguageList(this.data) {
    if (data.isEmpty) throw ArgumentError('Provide at least one element');
  }
  
  final List<String> data;

  @override
  bool operator ==(Object other) => other is LanguageList && ListEquality<String>.equals(data, other.data);

  @override
  int get hashCode => DeepCollectionEquality().hash(data);
}

and use it in the Options model:

factory Options._internal({required LanguageList languages}) = _Options;

You can even make it more "compile-friendly" by making illegal states unrepresentable instead of throwing an error in runtime:

class LanguageList {
  LanguageList(String head, Iterable<String> tail) : data = [head, ...tail];
  
  final List<String> data;

  @override
  bool operator ==(Object other) => other is LanguageList && ListEquality<String>.equals(data, other.data);

  @override
  int get hashCode => DeepCollectionEquality().hash(data);
}

In this case there's just no way to create wrong instance.

Kirill Bubochkin
  • 5,868
  • 2
  • 31
  • 50
  • Though I like this solution, I am looking for way of expressing exactly this behavior for a data class built with the freezed package. As far as I can see, there is currently no way of achieving this – kevlatus Apr 05 '21 at 19:40
0

To make invalid state unrepresentable, you need constructor validation. If the class is mutable, you need to validate when mutated as well, of course. None of the solutions above seem all that helpful. The best solution I have is to not use Freezed. Freezed is a great package that does really a lot. But, it maybe YAGNI in many cases. So, if you don't need all the features, bring in packages that provide just those you actually do need and hand roll those that for which there is no package.

See this discussion with Remi: https://github.com/rrousselGit/freezed/issues/830

Bill Turner
  • 869
  • 1
  • 13
  • 27