Convenience Types

A package of convenience types commonly used in Flutter projects developed by Capyba.

Motivation

Across our projects we have adopted types that keep code safer, less error-prone and, in the long run, more productive. In order to share those types between the projects we work, and possibly to inspire others to use those types too, we have created this package.

Table of contents

  1. Getting Started
  2. Types
    1. Result
    2. Maybe
    3. Unit
    4. RequestStatus
    5. FormField
    6. SizingInformation
    7. NoParams
    8. ValueObject
    9. UseCase
  3. AppError
  4. ValueErrors
  5. Util
    1. FormUtils
    2. SeedTestStateMixin

Getting started

To install and have the package good to go in a Flutter project, run:

flutter pub add convenience_types

If you're on a Dart project, run:

dart pub add convenience_types

Types

Result

A type-safe way to model operations that can succeed or fail. Result<ResultType> is a union type with two variants:

  • Success<ResultType> — carries a value of type ResultType
  • Failure — carries an AppError describing what went wrong

Using Result instead of throwing or nullable return values makes both outcomes explicit and encourages handling them via handle, pattern matching, or the mapSuccess / mapFailure methods.

Handle both outcomes:

Result<String> result = await fetchUserName();
final message = result.handle(
  onSuccess: (name) => 'Hello, $name',
  onFailure: (error) => 'Error: ${error.message}',
);

Pattern matching:

switch (result) {
  Success(:final data) => print(data),
  Failure(:final error) => showError(error),
}

Chaining: mapSuccess and mapAsyncSuccess transform the value when Success and pass through Failure. mapFailure and mapAsyncFailure transform the error when Failure; when Success, they return this Result unchanged.

Conversion: The maybeData getter converts to Maybe: SuccessJust(data), FailureNothing.

Note: asSuccess and asFailure are casting helpers; they throw if the variant is wrong. Prefer handle or pattern matching instead.

Maybe

A type-safe, declarative way to model optional values. Maybe<T> is a union type with two variants:

  • Nothing — no value (type-safe alternative to null)
  • Just<T> — a value of type T

Using Maybe instead of nullable types (T?) makes the presence or absence of a value explicit and encourages handling both cases via pattern matching or methods like mapJust, mapNothing, and getOrElse.

Pattern matching:

Maybe<String> name = Just("test");
final display = switch (name) {
  Nothing() => "",
  Just(:final value) => value,
};

From nullable input:

Maybe.from(null);   // Nothing()
Maybe.from("hi");   // Just("hi")

Chaining: mapJust / mapAsyncJust transform the value when Just and preserve Nothing. mapNothing / mapAsyncNothing run a callback when Nothing and return this Maybe unchanged when Just. Use getOrElse(fallback) to get the value or a fallback when Nothing (or when the inner value is null).

Combining two Maybes: On a record (Maybe<K>, Maybe<J>), call maybeCombine (or maybeAsyncCombine) with optional callbacks for firstJust, secondJust, bothJust, and bothNothing; omitted callbacks yield Nothing.

final combined = (maybeName, maybeCount).maybeCombine<String>(
  bothJust: (name, count) => Just('$name: $count'),
  firstJust: (name) => Just(name),
  secondJust: (count) => Just(count.toString()),
  bothNothing: () => Just('unknown'),
);

Unit

A type that represents the absence of a meaningful value. Unit has exactly one value, Unit(), and carries no data. Use it when you need a type-safe way to express "no value" or "success with nothing to return", for example:

  • Result<Unit> for operations that succeed but return nothing (e.g. delete, logout)
  • Callbacks or generic code that require a concrete type instead of void
Future<Result<Unit>> deleteItem(String id) async {
  await api.delete(id);
  return Result.success(Unit());
}

The package also provides an identity function identity<T>(T value) => value for use in generic code or as a no-op transformation (e.g. list.map(identity)).

RequestStatus

When one is dealing with ui responses to different request states, in the course of it, usually there are four states of interest: Idle, Loading, Succeeded or Failed.
So the convenience generic union type

RequestStatus<ResultType>

serves the purpose of modeling those states. Idle and Loading, carry no inner state, but

Succeeded<ResultType>().data = ResultType data;

contains a field data of type ResultType. And the

Failed().error = AppError error;

contains a field error of type AppError. Where AppError is the convenience type that models errors in the app.
To deal with the request states one should use one of the unions methods.
The .map forces you to deal with all the four states explicitly, passing callbacks for each state with non-destructured states. Example:

  Widget build(context) {
    final someRequestStatus = someStateManagement.desiredRequestStatus;
    return someRequestStatus.map(
              idle: (idle) => "widget for idle state",
              loading: (loading) => "widget for loading state",
              succeeded: (succeeded) => "widget for succeeded state using possibly data within succeeded.data",
              failed: (failed) => "widget for failed state using possibly AppError within failed.error",
          );
  }

The .when forces you to deal with all the four states explicitly, passing callbacks for each state with destructured states. Example:

  Widget build(context) {
    final someRequestStatus = someStateManagement.desiredRequestStatus;
    return someRequestStatus.when(
              idle: () => "widget for idle state",
              loading: () => "widget for loading state",
              succeeded: (data) => "widget for succeeded state using possibly data within data",
              failed: (error) => "widget for failed state using possibly AppError within error",
          );
  }

You can also use .maybeMap and .maybeWhen, passing only the cases you care about and an orElse callback for the rest. Example:

  Widget build(context) {
    final someRequestStatus = someStateManagement.desiredRequestStatus;
    return someRequestStatus.maybeWhen(
              orElse: () => "default widget to be displayed when the current state is not specified in other callbacks",
              loading: () => "widget for loading state",
              succeeded: (data) => "widget for succeeded state using possibly data within succeeded.data",
          );
  }

So, RequestStatus provides a safe and declarative way to always deal with all possible or desired states of a request.

Maybe<ResultType> get maybeData;

Getter that results in a Maybe that is Just if the RequestStatus is Succeeded and Nothing otherwise.

FormField

When providing data to a form and then passing it forward, for instance, in a request body, one problem that is common here is the need of dealing with the cases where the field is not filled, and then one might need to treat every possible resulting Map (json) separately, either passing the not filled field with no value or not passing it at all.

The generic sealed data class

FormField<Type>

is a convenience type that models, as the name already points, a field in a Form, and uses the convention of not passing not filled fields to the resulting Map. Here we are already passing the name of the field in its possible Map (json) position, and the actual field data is a Maybe<Type>.
FormFields are usually used in a Form defined class, and with the usage of our convenience mixin FormUtils, one should have everything it needs to have form validation, and toJson method. It might introduce some verbose api, to deal with, but the convenience of dealing with the most critical parts, like validating and passing the Form information through, makes the usage of our FormFields worthwhile.
Example (using freezed to create the Form class):

 @freezed
 class FormExampleWithFreezed with _$FormExampleWithFreezed, FormUtils {
   const FormExampleWithFreezed._();
   const factory FormExampleWithFreezed({
     @Default(FormField(name: 'firstFieldJsonName'))
         FormField<String> firstField,
     @Default(FormField(name: 'secondFieldJsonName'))
         FormField<String> secondField,
   }) = _FormExampleWithFreezed;

   Result<String> get firstFieldValidation => validateField(
         field: firstField.field,
         validators: <String? Function(String)>[
           // list of validators to first field
         ],
       );

   Result<String> get secondFieldValidation => validateField(
         field: secondField.field,
         validators: <String? Function(String)>[
           // list of validators to second field
         ],
       );

   Map<String, dynamic> toJson() => fieldsToJson([
         firstField,
         secondField,
       ]);
 }

Just to point out that the usage of a freezed class is not required to enjoy the advantages of the FormField type, we present another example(not using freezed):

class FormExample with FormUtils {
  final FormField<String> firstField;
  final FormField<String> secondField;

  const FormExample({
    required this.firstField,
    required this.secondField,
  });

  Result<String> get firstFieldValidation => validateField(
        field: firstField.field,
        validators: <String? Function(String)>[
          // list of validators to first field
        ],
      );

  Result<String> get secondFieldValidation => validateField(
        field: secondField.field,
        validators: <String? Function(String)>[
          // list of validators to second field
        ],
      );

  Map<String, dynamic> toJson() => fieldsToJson([
        firstField,
        secondField,
      ]);
}

Using a Form class as presented, one has a safe way to pass the values of the field to a request body with ease.
Example:

  request.body = formExampleInstance.toJson(),

SizingInformation

Holds responsive layout information: ScreenType (Small / Medium / Large) and Size. Use with LayoutBuilder or MediaQuery to adapt UI to screen size. ScreenType.fromWidth(double) returns Small for width ≤ 670, Large for width > 1500, and Medium otherwise.

NoParams

A type representing "no parameters" for use cases or callbacks that take no arguments. Use as the parameter type for UseCase when the operation needs no input (e.g. UseCase<T, NoParams>).

ValueObject

Base type for domain value objects whose validity is represented by Result<T>. Subclasses expose value (a Result); isValid is true when value is Success. Use getOrCrash to obtain the value or throw UnexpectedValueError on failure. failureOrUnit converts value to Result<Unit> (Success → Unit, Failure → same Failure).

UseCase

Base type for a use case: a single async operation that takes Params and returns Result<T>. Implement call(Params params) returning Future<Result<T>>. Use NoParams for use cases that take no input.

AppError

Abstract class to model errors in the application. As a preset of foreseen specific errors there are several implementations of this type. Namely: HttpError models errors related to http requests CacheError models cache errors DeviceInfoError models device's information gathering related errors FormError models form related errors StorageError models storage operations related errors

In addition to the AppError, there are a preset of foreseen Exceptions.

ValueErrors

ValueError extends AppError for value validation failures. UnexpectedValueError<T> is thrown when a ValueObject is read via getOrCrash and its Result is Failure; it holds the failing Result and optional msg/slug/stackTrace. Preset value errors include: InvalidEmail, InvalidPassword, InvalidUserName, InvalidName, InvalidOTP, DescriptionTooShort, DescriptionTooLong, InsufficientDetail, InvalidCharacters.

Util

FormUtils

Class used as a Dart Mixin on a Form class, providing methods to conveniently deal with validation and serialization of fields.

  Result<String> validateField<Type>

Method to help validate a FormField<Type> providing its value represented by its Maybe<Type>, and a List<Validator<Type>>, returning a Result<String> with possible error message.

  Map<String, dynamic> fieldsToJson(List<FormField> fields)

Method to help in the task of passing the provided List<FormField> to its Map<String, dynamic> representation, that is useful when it comes to pass the Form data through, for instance, a request body

SeedTestStateMixin

Mixin to StateNotifier to help seeding test states.

Example:

class MyStateNotifier extends StateNotifier<MyState> with SeedTestStateMixin<MyState> {}

and in a test:

test(
        'Test description',
        () {
          myStateNotifier.setSeedState(
            mySeedState
          );

          /// test body
        },
      );