Error Handling
In the previous section we introduced how to handle numeric input using mutable computed cells. However, we glossed over what happens if the user enters invalid input.
When a cell created by mutableString() is assigned a string which
does not represent a valid number, a default value of 0 is
assigned. This default value can be changed using the errorValue
argument:
final a = MutableCell<num>(0);
final strA = a.mutableString(
errorValue: -1.cell
);
strA.value = 'not a valid number';
print(a.value); // Prints -1
In this example, cell a is assigned a value of -1 if strA is
assigned a string which does not represent a valid number.
The errorValue is a cell, which allows the default value to be
changed dynamically.
Maybe Values
This error handling strategy might be sufficient for some cases but
usually, we want to detect and handle the error rather than assigning
a default value. This can be done with
Maybe
values.
Maybe
is a container that either contains a value, or an exception that was
thrown during the computation of a value. This can be used to catch
exceptions thrown inside the reverse computation function of a mutable
computed cell.
The
Maybe.wrap
constructor calls the function passed to it and wraps the return
value, or the exception that was thrown, in a Maybe value.
// Creates a Maybe holding the integer value 1
final maybeInt = Maybe.wrap(() => int.parse('1'));
// Creates a Maybe holding a FormatException
final maybeError = Maybe.wrap(() => int.parse('junk'));
Maybe is a sealed union of the classes
MaybeValue
and
MaybeError. This
allows you to handle errors using switch and pattern matching:
switch (maybe) {
case MaybeValue(:final value):
/// Do something with `value`
case MaybeError(:final error):
/// Handle the `error`
}
The
unwrap
property returns the value held in the Maybe. If the Maybe holds
an exception, it is rethrown.
// Creates a Maybe holding the integer value 1
final maybeInt = Maybe.wrap(() => int.parse('1'));
// Prints 1
print(maybeInt.unwrap);
// Creates a Maybe holding a FormatException
final maybeError = Maybe.wrap(() => int.parse('junk'));
// Throws the FormatException held in the Maybe
print(maybeError.unwrap);
Maybe Cells
The
maybe()
extension method creates a cell that wraps the value of its argument
cell in a Maybe. The resulting Maybe cell is a mutable computed
cell with the following behaviour:
- Its computed value is the value of the argument cell wrapped in a
Maybe. - When the cell's value is set, it sets the value of the argument cell
to the value wrapped in the
Maybeif it is holding a value. - When the cell's value is set to a
Maybeholding an exception, the argument cell's value is not changed.
The following cell definition:
final a = MutableCell<num>(0);
final maybeA = a.maybe();
is equivalent to:
final maybeA = MutableCell.computed(
() => Maybe.wrap(() => a()),
(maybe) => a.value = maybe.unwrap
);
Maybe cells provide an
error
property which retrieves a ValueCell that evaluates to the exception
held in the Maybe or null if the Maybe is holding a value.
This can be used to determine whether an error occurred while assigning a value to a mutable computed cell.
final a = MutableCell<num>(0);
final maybeA = a.maybe();
final strA = MutableCell.computed(() => a().toString(), (value) {
maybeA.value = Maybe.wrap(() => num.parse(value));
}
print(strA.value); // Prints 0
strA.value = '12';
print(a.value); // Prints 12
print(maybeA.error.value); // Prints null
strA.value = 'junk';
print(a.value); // Prints 12
print(maybeA.error.value); // Prints FormatException
In this example strA is a mutable computed cell that:
- Converts the value it is assigned to a
num - Wraps it in a
Maybevalue - Assigns the
Maybevalue tomaybeA, which in turns sets the value ofato the wrapped value.
When strA is assigned a value that is not a valid num, maybeA is
assigned a maybe that holds an exception which can be retrieved using
maybeA.error. The value of a is not changed in this case.
A cell with this behaviour can be created using the
mutableString
method provided on Maybe cells that hold num, int or double
values.
Putting it all together a text field for numeric input that displays an error message when an invalid value is entered can be implemented with the following:
class NumberField extends CellWidget {
final MutableCell<num> n;
const NumberField(this.n, {
super.key
});
Widget build(BuildContext context) {
final maybe = n.maybe();
final error = maybe.error;
return LiveTextField(
content: maybe.mutableString(),
decoration: InputDecoration(
errorText: error() != null
? 'Please enter a valid number'
: null
)
);
}
}
We've packaged the input field in a CellWidget subclass which
takes the cell to which to bind the content of the field as an
argument. This allows us to reuse this error handling logic wherever a
numeric input text field is required.
Here we're testing whether error is non-null, that is whether an
error occurred while parsing a number from the text field, and if so
providing an error message in the errorText of the
InputDecoration.
The error message can be made more descriptive by also checking whether the field is empty, or not:
class NumberField extends CellWidget {
final MutableCell<num> n;
const NumberField(this.n);
Widget build(BuildContext context) {
final maybe = n.maybe();
final content = maybe.mutableString();
final error = maybe.error;
return LiveTextField(
content: content,
decoration: InputDecoration(
errorText: content().isEmpty
? 'Cannot be empty'
: error() != null
? 'Please enter a valid number'
: null
)
);
}
}
Now that we have a reusable numeric input field with error handling, let's use it to reimplement the sum example from earlier.
CellWidget.builder((_) {
final a = MutableCell<num>(0);
final b = MutableCell<num>(0);
final sum = a + b;
return Column(
children: [
Row(
children: [
NumberField(a),
SizedBox(width: 5),
Text('+'),
SizedBox(width: 5),
NumberField(b),
],
),
Text('${a()} + ${b()} = ${sum()}'),
FilledButton(
child: Text('Reset'),
onPressed: () => MutableCell.batch(() {
a.value = 0;
b.value = 0;
})
)
]
);
});
Notice how we were able to package our text field with error handling
entirely in a separate class, that can be reused, all without writing
or passing a single onChanged callback and at the same time being
able to reset the content of the fields simply by changing the values
of the cells holding our data.
The same cell should be provided to NumberField between
builds. Don't conditionally select between multiple cells. Don't do
this:
NumberField(cond ? n1 : n2)
Don't do this either:
cond ? NumberField(n1) : NumberField(n2)
If you need to do this consider provided a key to NumberField that
is changed whenever a different cell is selected.