Convenience Types
A package to ensamble convenience types commonly used through flutter projects developed by Capyba.
Motivation
Along our development cycle of numerous projects we have adopted the usage of some types that helped us to keep things safer, more 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
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
Every asynchronus task can have two possible outcomes as a Result
.
It is either a Success
or a Failure
.
So the
Result<ResultType>
generic union type is a convenience type to model and help safelly deal with any asynchronus task outcomes.
The approach is declarative, so in order to deal with the result, one
should call the handle
method which has two required parameters
an onSuccess
callback
Type onSuccess(Type data)
and an onFailure
callback
Type onFailure(AppError data)
Where AppError is a convenience type to model errors in the application
Example:
Result<String> asyncTaskResturningStringResult = await someFutureOfResultString();
asyncTaskResturningStringResult.handle(
onSuccess: (String data) {
"here one have access to the succesful value of the async task and might use it as desired"
},
onFailure: (AppError error) {
"here one have access to the failure modeled as AppError representing this async task"
}
);
In this way one always needs to deal in a declarative way with both the success and failure possible outcomes as unfortunatelly any asynchronus task needs.
anti-patern alert!
: The Result
generic Union type comes with casts convenience methods asSuccess
, asFailure
, but although it might be temping to just cast the result into the desired type, it is strongly advised you not to do it, once if you try to cast diferent types (Success
as Failure
or the other way around) it would throw an exception.
Result<K> mapSuccess<K>(
Result<K> Function(ResultType) combiner,
);
A method used to chain access to data held by the Result. If this
is Failure returns Failure, if this
is Success, returns the result of the combiner
method over the data
inside Success
Example:
Result<String> asyncTaskResturningStringResult = await someFutureOfResultString();
Result<double> parseResult = asyncTaskResturningStringResult.mapSuccess((String data) => methodThatTakesStringDataAndTriesToParseDouble(data));
FutureOr<Result<K>> mapAsyncSuccess<K>(
FutureOr<Result<K>> Function(ResultType) combiner,
);
A method to chain asynchronous access to data held by the Result. If this
is Failure returns [FutureOr<Failure>]
, if this
is Success, returns the result of the combiner
method over the data
inside Success
Example:
Result<String> asyncTaskResturningStringResult = await someFutureOfResultString();
Result<double> parseResult = await asyncTaskResturningStringResult.mapAsyncSuccess((String data) => methodThatTakesStringDataAndAsynchronouslyTriesToParseDouble(data));
Maybe<ResultType> get maybeData;
Getter that results in a Just if the Result is Success and Nothing othterwise
Maybe
Dealing with optional values in ui has always been verbose and unsafe. So the
Maybe<T>
generic union type is a convenience type to model and help safelly deal with any optional value outcomes.
Where we can have two types that will represent the state of a value that can be null. The Nothing, representing when it has no value, and the Just, when it has a value.
The approach is declarative, so in order to deal with the states of Maybe, one should use one of the unions methods.
The .map
forces you to deal with all the two states explicitly, passing callbacks for
each state with undestructured states.
Example:
Maybe<String> someMaybeValue = Just("test");
final debugValue = someMaybeValue.map(
nothing: (_) => "",
just: (data) => data.value,
);
print(debugValue); // test
The .when
forces you to deal with all the two states explicitly, passing callbacks for
each state with destructured states
Example:
Maybe<String> someMaybeValue = Nothing();
final debugValue = someMaybeValue.map(
nothing: () => "test",
just: (data) => data,
);
print(debugValue); // test
and one also might want to not deal explicitly with all states diferently, so
there are the .maybeMap
, and .maybeWhen
methods where you need only expclitly to pass a
orElse
callback. But I would say that it is not so useful in this case since we only have two states to be treated.
Example:
Maybe<String> someMaybeValue = Just("test");
final debugValue = someMaybeValue.maybeWhen(
just: (data) => data,
orElse() => "",
);
print(debugValue); // test
So, Maybe
provides a safe and declarative way to always deal with the two possible states of a optional value.
factory Maybe.from(T? input);
Factory for helping building a Maybe from a nullable input. It produces a Nothing if the input is null, and a Just otherwise
Type getOrElse<Type>(Type fallback);
The getOrElse
method which receives a parameter to return as a
fallback value, when the value is a Nothing, or there is no value in the Just
Maybe<K> mapJust<K>(Maybe<K> Function(T) combiner);
A method to chain access to data held by the Maybe. If this
is Nothing returns Nothing, if this
is Just, returns the result of the combiner
method over the value
inside Just
FutureOr<Maybe<K>> mapAsyncJust<K>(FutureOr<Maybe<K>> Function(T) combiner);
A Method to chain async access to data held by the Maybe. If this
is Nothing returns Nothing, if this
is Just, returns the result of the combiner
method over the value
inside Just
Maybe<T> maybeCombine<T>({
/// Used to map case where only the first value is [Just]
Maybe<T> Function(K)? firstJust,
/// Used to map case where only the second value is [Just]
Maybe<T> Function(J)? secondJust,
/// Used to map case where both values are [Just]
Maybe<T> Function(K, J)? bothJust,
/// Used to map case where both values are [Nothing]
Maybe<T> Function()? bothNothing,
})
Use it to combine two different Maybe's into a new one. Input firstJust
to map case where only the first value is Just, secondJust
to map case where only the second value is Just, bothJust
to map case where both first and second value are Just and bothNothing
to map case where both are Nothing
Example:
Maybe<Number> combined = (testString, testInt).maybeCombine<Number>(
bothJust: (val, number) => Just(Number(val, number, '$number$val',)),
firstJust: (val) => Just(Number(val, -1, '-1$val',)),
secondJust: (number) => Just(Number('not a trivia', number, 'NonTrivia',)),
bothNothing: () => Just(Number('not a trivia', -1, 'NonTrivia',)),
);
```
### 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`, `Succeded` or `Failed`.<br>
So the convenience generic union type
```dart
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 undestructured 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",
);
}
and one also might want to not deal explicitly with all states diferently, so
there are the .maybeMap
, and .maybeWhen
methods where you need only expclitly to pass a
orElse
callback.
Example:
Widget build(context) {
final someRequestStatus = someStateManagement.desiredRequestStatus;
return someRequestStatus.maybeWhen(
orElse: () => "default widget to be displayed other wise 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 than one might need to
treat every possible resulting Map (json) separetily, 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>
.
FormField
s are usually used in a Form
defined class, and with the usage of
our convinice 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
FormField
s 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 easy.
Example:
request.body = formExampleInstance.toJson(),
AppError
Abstract class to model errors on the application. As a presset of foreseen specific errors there are some different 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 presset of foreseen Exceptions
Util
FormUtils
Class used as a dart Mixin
to a Form
class, providing methods to conviniently
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
},
);
Libraries
- convenience_types
- errors/app_error
- errors/cache_error
- errors/default_error_messages
- errors/device_info_error
- errors/errors
- errors/exceptions
- errors/form_error
- errors/http_error
- errors/storage_error
- types/form_field
- types/maybe
- types/request_status
- types/result
- types/types
- util/form_utils
- util/seed_test_state_mixin
- util/utils