nidula 0.0.3 nidula: ^0.0.3 copied to clipboard
A lightweight Dart library for Rust-like Option/Result types. Supports exhaustive pattern matching and compile-time safe, chainable None/Err propagation.
nidula #
nidula
is a lightweight library bringing Rust's
Option and
Result types to Dart, together with
a try operator that is both compile-time safe and chainable.
nidula
is a fork of option_result
, which
brings the following enhancements:
- Try-operator implementation rewritten from scratch.
- Compile time safety through propagation tokens.
- Chainable.
- Simpler and clearer library-internal error handling strategy.
T
andE
types must extendObject
(thus, non-null values are prohibited).- This enforces composition with
Option
types (Option<T>
) instead of nullable types (T?
).
- This enforces composition with
- Only
T v
andE e
fields (and, thus, getters) are available.value
,val
,err
anderror
aliases were removed.
- There is only a single public library to import components from.
- Final modifiers to prevent extending
Ok
,Err
,Some
andNone
. ==
operator takes all generic types into consideration when comparingOption
objects andResult
objects.- Added variable names to all function parameters in types.
- Callback autocomplete outputs e.g.
(v) {}
instead of(p0) {}
.
- Callback autocomplete outputs e.g.
This library aims to provide as close to a 1:1 experience in Dart as possible to
Rust's implementation of these types, carrying over all of the methods for composing
Option
and Result
values (and_then()
, or_else()
, map()
, etc.) and allowing
the use of Dart 3's new exhaustive pattern matching to provide a familiar experience
while working with Option
and Result
type values.
Overview #
Option #
Option
types represent the presence (Some
) or absence (None
) of a value.
Dart handles this pretty well on its own via null
and a focus on null-safety built
in to the compiler and analyzer, but we can do better.
The advantage of Option
types over nullable types lies in their composability.
Option
type values have many methods that allow composing many Option
-returning
operations together and helpers for propagating None
values in larger operations
without the need for repetitive null-checking.
This supports writing clean, concise, and most importantly, safe code.
Option<int> multiplyBy5(int i) => Some(i * 5);
Option<int> divideBy2(int i) => switch (i) {
0 => None(),
_ => Some(i ~/ 2)
};
Option<int> a = Some(10);
Option<int> b = Some(0);
Option<int> c = None();
Option<int> d = a.andThen(divideBy2).andThen(multiplyBy5); // Some(25)
Option<int> e = b.andThen(divideBy2).andThen(multiplyBy5); // None()
Option<int> f = c.andThen(divideBy2).andThen(multiplyBy5); // None()
For safety, operations culminating in an Option
that make use of other Option
values in their logic where the outcome is dependent on those Option
values can
benefit from None
value propagation via Option.catchProp
:
// Example from Option.catchProp docstring
Option<int> example2(Option<int> o) {
var l = o.map(identity); // initial `l`: a copy of `o`
return Option.catchProp<int>((t) {
l = Some(l.prop(t) + [1, 2, 3].elementAt(1));
// it will propagate now if initial `l` was None, else continues
l = None(); // not propagating yet
l.prop(t); // now it will propagate now if initial `l` was Some
l = Some(l.prop(t) + [5, 6].elementAt(1)); // dead code (not shown by IDE)
return Some(l.prop(t));
});
}
Option<int> myOption = example(Some(9));
switch (myOption) {
case Some(:int v): print('Contained value: $v');
case None(): print('None');
}
The t
in the catchProp
callback is an instance of OptionPropToken
guaranteeing
compile-time safety (due to OptionPropToken
having a private constructor). If a t
of
the OptionPropToken
is not provided, then l.prop(t)
cannot be invoked.
There is also an Option.catchPropAsync
for async functions.
Note that the prop
method allows chaining, for example: return Ok(a.prop(t).makeCall().prop(t).makeSecondCall().prop(t))
, where makeCall
and makeSecondCall
must be methods defined in T
returning Result<T, E>
.
Result
's call
method returns prop
, therefore one can replace the l.prop(t)
calls in example2
with l(t)
,
which results in much more concise code. The chain above is thus equivalent to return Ok(a(t).makeCall()(t).makeSecondCall()(t))
.
Result #
Result
types represent the result of some operation, either success (Ok
), or
failure (Err
), and both variants can hold data.
This promotes safe handling of error values without the need for try/catch blocks
while also providing composability like Option
via methods for composing Result
-returning
operations together and helpers for propagating Err
values within larger operations
without the need for repetitive error catching, checking, and rethrowing.
Again, like Option
, this helps promote clean, concise, and safe code.
Result<int, String> multiplyBy5(int i) => Ok(i * 5);
Result<int, String> divideBy2(int i) => switch (i) {
0 => Err('divided by 0'),
_ => Ok(i ~/ 2),
};
Result<int, String> a = Ok(10);
Result<int, String> b = Ok(0);
Result<int, String> c = Err('foo');
Result<int, String> d = a.andThen(divideBy2).andThen(multiplyBy5); // Ok(25)
Result<int, String> e = b.andThen(divideBy2).andThen(multiplyBy5); // Err('divided by 0')
Result<int, String> f = c.andThen(divideBy2).andThen(multiplyBy5); // Err('foo')
And, you guessed it, like Option
, Result
types can also benefit from safe propagation
of their Err
values using Result.catchProp
:
// Example from Result.catchProp docstring
Result<double, String> example2(Result<double, String> r) {
var s = r.map(identity); // initial `s`: a copy of `r`
return Result.catchProp((t) {
s = Ok(s.prop(t) / 2); // it will propagate now if initial `s` was Err
s = Err('not propagating yet');
s.prop(t); // now it will propagate now if initial `s` was Ok
s = Ok(s.prop(t) / 0); // dead code (not shown by IDE)
return Ok(s.prop(t));
});
}
Result<double, String> myResult = example2(Ok(0.9));
switch (myResult) {
case Ok(:double v): print('Ok value: $v');
case Err(:String e): print('Error: $e');
}
There is also an Result.catchPropAsync
for async functions.
Empty tuple
But Result
doesn't always have to concern data. A Result
can be used strictly
for error handling, where an Ok
simply means there was no error and you can safely
continue. In Rust this is typically done by returning the
unit type ()
as Result<(), E>
and the same can be done in Dart with an empty Record
via ()
.
Result<(), String> failableOperation() {
if (someReasonToFail) {
return Err('Failure');
}
return Ok(());
}
Result<(), String> err = failableOperation();
if (err case Err(e: String error)) {
print(error);
return;
}
// No error, continue...
To further support this, just like how you can unwrap Option
and Result
values
by calling them like a function, an extension for Future<Option<T>>
and Future<Result<T, E>>
is provided to allow calling them like a function as well which will transform the
future into a future that unwraps the resulting Option
or Result
when completing.
(This also applies to FutureOr
values.)
// Here we have two functions that return Result<(), String>, one of which is a Future.
// We can wrap them in a catchPropAsync block (async in this case) and call them like a function
// to unwrap them, discarding the unit value if Ok, or propagating the Err value otherwise.
Result<(), String> err = await Result.catchPropAsync((t) async {
(await failableOperation1()).prop(t);
failableOperation2().prop(t);
return Ok(());
});
if (err case Err(e: String error)) {
print(error);
return;
}
// No error, continue...
Note that just like how unit
has one value in Rust, empty Record
values in
Dart are optimized to the same runtime constant reference so there is no performance
or memory overhead when using ()
as a unit
type.
Try-catch warning #
Using try catch in combination with Result.catchProp/Async
or Option.catchProp/Async
can be
dangerous.
If the try block wraps the catchProp/Async
function and there is no outer catchProp/Async
wrapping the try-catch
block, then it is fine. For example:
Result<double, String> example3(Result<double, String> r) {
var s = r.map(identity); // initial `s`: a copy of `r`
try {
return Result.catchProp((t) {
s = Ok(s.prop(t) / 2); // it will propagate now if initial `s` was Err
throw 'example';
s = Err('not propagating yet'); // dead code
s.prop(t);
s = Ok(s.prop(t) / 0);
return Ok(s.prop(t));
});
} on String {
return Err('caught a String');
}
}
However, a try-catch inside the catchProp
's callback or any function it calls then we must be a little careful.
Bad example
The next example catches also ResultPropError<String>
, which compromises the error propagation.
Result<double, String> badExample(Result<double, String> r) {
var s = r.map(identity);
return Result.catchProp<double, String>((t) {
try {
s = Ok(s.prop(t) / [1,2,3].elementAt(100));
} catch (e) {
s = Err('index too high');
}
return Ok(s.prop(t));
});
}
Good — Catch specific errors if possible
Catching the exact exceptions/errors that might be thrown — thus, avoiding
catching all possible errors with } on catch (e) {
— would be the
ideal approach:
Result<double, String> goodExample1(Result<double, String> r) {
var s = r.map(identity);
return Result.catchProp<double, String>((t) {
try {
s = Ok(s.prop(t) / [1,2,3].elementAt(100));
} on RangeError catch (e) {
s = Err('index too high');
}
return Ok(s.prop(t));
});
}
Good — When catching specific errors is not possible
If it is not possible to catch the exact errors, or there would be too many
to distinguish from, then rethrow ResultPropError
:
Result<double, String> goodExample2(Result<double, String> r) {
var s = r.map(identity);
return Result.catchProp<double, String>((t) {
try {
s = Ok(s.prop(t) / [1,2,3].elementAt(100));
} on ResultPropError { // gen. type must be <Object> (and not <String>)
rethrow; // always rethrow so that the contained error propagates
} catch (e) {
s = Err('index too high');
}
return Ok(s.prop(t));
});
}
Note: make sure you catch and rethrow ResultPropError<Object>
(which is the same as ResultPropError
), and not ResultPropError<String>
,
as there might be nested [Result.catchProp]/[Result.catchPropAsync]
with different [E] generic types.
Key differences from Rust #
Option
andResult
types provided by this library are immutable. All composition methods either return new instances or the same instance unmodified if applicable, and methods for inserting/replacing values are not provided.
The benefits of immutability speak for themselves, but this also allows compile-timeconst
Option
andResult
values which can help improve application performance.- This library lacks all of the methods Rust's
Option
andResult
types have that are related toref
,deref
,mut
,pin
,clone
, andcopy
due to not being applicable to Dart as a higher-level language. - The Option.filter()
method has been renamed
where()
to be more Dart-idiomatic. - The
Option
andResult
methodsmapOr
,mapOrElse
returnOption<U>
andResult<U, E>
respectively to aid composition ofOption
andResult
values. The encapsulated values of these types should never leave the context ofOption
orResult
unless explicitly unwrapped via the designated methods (unwrap()
,expect()
, etc.). None()
/Err()
propagation is not supported at the language-level in Dart since there's no concept of it so it's not quite as ergonomic as Rust, but is still quite comfy and easily managed via the provided helpers.