flutterdartgenericsdiscriminated-unionfreezed

Discrimination type Unions in Dart with freezed, with overloading constructors or related union values union


I would like to know if there is any good approach to the following problem in Flutter / Dart.

I would like to define a Result type as a discriminated union using the freezed package.

I'll present in a bit what I have currently, but first the desired features:

I'll show you what I mean. I have the following code.

@freezed
sealed class Result<T> with _$Result<T> {
  const Result._();

  // default success constructor with no value and no message, and is used only 
  // to indicate a successful operation
  const factory Result.success() = Success;

  // success constructor with a generic value resulting from the operation
  const factory Result.successValue(T value) = SuccessValue<T>;

  // success constructor with an explicit message and an optional generic value
  // can be used in UI elements like Dialogs, Snackbars, etc.
  const factory Result.successMessage(String message, [T? value]) =
      SuccessMessage<T>;

  // error constructor with an optional message
  const factory Result.error([String? message]) = Error;

  bool get isSuccess =>
      this is Success || this is SuccessMessage || this is SuccessValue;
}

I feel like I'm lacking a piece of 'type safety' when it comes to Success comparisons. What I would actually want, is that all the constructor types 'Success | SuccessValue | SuccessMessage ' are all related and recognized as a general 'Success' type. Either they should be overloads of the same type, or somehow inheritance between a parent 'Success' type, and more restricted children types 'SuccessValue and SuccessMessage' should be defined.

This is the reason that I expose an isSuccess getter, but the mistake can still be made by a developer that the erroneous comparison "result is Success" can be made.


Solution

  • It feels like a bit of a hack, but you can use the @Implements annotation to join all the success types together:

    import 'package:freezed_annotation/freezed_annotation.dart';
    
    part 'freezed_result_other.freezed.dart';
    
    sealed class Success<T> {}
    
    @freezed
    sealed class Result<T> with _$Result<T> {
      @Implements<Success<T>>()
      factory Result.successEmpty() = SuccessEmpty<T>;
    
      @Implements<Success<T>>()
      factory Result.successVal(T value) = SuccesVal<T>;
    
      @Implements<Success<T>>()
      factory Result.sucessMessage(String message, [T? value]) = SuccessMessage<T>;
    
      factory Result.error([String? message]) = OError;
    }
    
    void example() {
      final Result<int> result = bar();
      switch (result) {
        case Success<int> s:
          switch (s) {
            case SuccessEmpty<int>():
            // TODO: Handle this case.
            case SuccesVal<int>(:final int value):
            // TODO: Handle this case.
            case SuccessMessage<int>(:final String message, :final int? value):
            // TODO: Handle this case.
          }
        case OError<int>():
        // TODO: Handle this case.
      }
    }
    

    Some parts of this are a bit odd though. Why does the value become nullable in the variant that includes a message? In the comments you clarified that in some cases a message will be required in addition to the value, but you can't express that in the type system if the value-only and message-carrying success variants are part of the same union. There's no way to say "This method returns a Result<T>, but in the success case it must be the SuccessMessage<T> variant, not just SuccessVal<T>". Finally, the SuccessEmpty<T> variant is out of place. If a method can return successfully but with or without a value, just make the generic type parameter nullable. If you just need to indicate success or failure and there's never a value, use Result<void>.

    Taking all that into account, here's a rewritten version with two separate Result types, one without a message and one with, and the presence or absence of an actual value left up to the generic type parameter:

    import 'package:freezed_annotation/freezed_annotation.dart';
    
    part 'freezed_result.freezed.dart';
    
    @freezed
    sealed class Result<T> with _$Result<T> {
      factory Result.success(T value) = SuccessWithValue<T>;
      factory Result.error([String? message]) = Error;
    }
    
    @freezed
    sealed class ResultWithMessage<T> with _$ResultWithMessage<T> {
      factory ResultWithMessage.success(T value, String message) =
          SuccessWithMessage;
      factory ResultWithMessage.error([String? message]) = ErrorWithMessage;
    }
    
    void example() {
      final Result<int> fooResult = foo();
      switch (fooResult) {
        case SuccessWithValue<int>(:final int value):
        // TODO: Handle this case.
        case Error<int>(:final String? message):
        // TODO: Handle this case.
      }
    
      final barResult = bar();
      switch(barResult) {
        case SuccessWithMessage<int>(:final int value, :final String message):
          // TODO: Handle this case.
        case ErrorWithMessage<int>(:final String? message):
          // TODO: Handle this case.
      }
    }
    
    Result<int> foo() {
      throw UnimplementedError();
    }
    
    ResultWithMessage<int> bar() {
      throw UnimplementedError();
    }
    

    It's also possible to write sealed classes without Freezed codegen, which might be preferable for simple cases like this. Doing so also let's us define our inheritance in ways that Freezes codegen doesn't support, such as reusing a single Error<T> type between Result<T> and ResultWithMessage<T>:

    sealed class Result<T> {
      const Result();
    }
    
    sealed class ResultWithMessage<T> {
      const ResultWithMessage();
    }
    
    class Success<T> extends Result<T> {
      const Success(this.value);
    
      final T value;
    }
    
    class SuccessWithMessage<T> extends ResultWithMessage<T> {
      SuccessWithMessage(this.value, {required this.message});
    
      final T value;
      final String message;
    }
    
    class Error<T> implements Result<T>, ResultWithMessage<T> {
      const Error([this.message]);
    
      final String? message;
    }
    

    Personally I would remove ResultWithMessage<T> entirely though. If a return type is required to include a message value, you can just nest that within the generic parameter:

    Result<(int, String)> intWithMessage() => // ...
    

    Or if you don't like records, you could define a dedicated type to combine the return type and a message:

    class ValueWithMessage<T> {
      ValueWithMessage(this.value, this.message);
      final T value;
      final String message;
    }
    
    Result<ValueWithMessage<int>> intWithMessage() => //...