nidula 0.1.1
nidula: ^0.1.1 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 parallel to Rust's try operator that is both compile-time safe and chainable.
nidula is a fork of option_result, which
brings the following enhancements:
- Parallel to Rust's try-operator implementation rewritten from scratch.
- Compile time safety through propagation tokens.
- Chainable.
- Simpler and clearer library-internal error handling strategy.
- Efficient, as no stacktraces are used for library internal propagations.
- Propagations are errors thrown with
StackTrace.emptyand are already caught and handled by this library.
- Propagations are errors thrown with
TandEtypes must extendObject(thus, non-null values are prohibited).- This enforces composition with
Optiontypes (Option<T>) instead of nullable types (T?).
- This enforces composition with
- Only
T vandE efields (and, thus, getters) are available.value,val,erranderroraliases were removed.
- There is only a single public library to import components from.
- Final modifiers to prevent extending
Ok,Err,SomeandNone. ==operator takes all generic types into consideration when comparingOptionobjects andResultobjects.- 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()
Parallel to Rust's try-operator implementation
With Option types, a parallel to Rust's try-operator is achieved combining unwrapOrPropagateNone with syncNonePropagationScope (for synchronous functions) or asyncNonePropagationScope (for asynchronous ones). For those unfamiliar with Rust, the try operator tries to unwrap the Option value, however, if the Option is a None, the try operator propagates None (this way we don't have to always write code for checking for and possibly returning the None; the more potential None cases there are, the more pragmatic this pattern becomes).
// Example from Option.syncNonePropagationScope docstring
Option<int> example2(Option<int> o) {
var l = o.map(identity); // initial `l`: a copy of `o`
return Option.syncNonePropagationScope<int>((t) {
l = Some(l.unwrapOrPropagateNone(t) + [1, 2, 3].elementAt(1));
// it will propagate now if initial `l` was None, else continues
l = None(); // not propagating yet
l.unwrapOrPropagateNone(t); // now it will propagate now if initial `l` was Some
l = Some(l.unwrapOrPropagateNone(t) + [5, 6].elementAt(1)); // dead code (not shown by IDE)
return Some(l.unwrapOrPropagateNone(t));
});
}
Option<int> myOption = example(Some(9));
switch (myOption) {
case Some(:int v): print('Contained value: $v');
case None(): print('None');
}
NonePropagationToken features a private constructor, thus l.unwrapOrPropagateNone(NonePropagationToken()) cannot be used to pass the required argument (and thus execute the method).
The provided argument t in the syncNonePropagationScope callback is an instance of NonePropagationToken, and is expected to be passed to unwrapOrPropagateNone. The propagation that is thrown inside the fn argument of syncNonePropagationScope must be handled by syncNonePropagationScope's body. If there is no syncNonePropagationScope, then there cannot be any a NonePropagationToken t, making l.unwrapOrPropagateNone impossible to invoke. Therefore, the NonePropagationToken guarantees compile-time safety.
The same holds for asyncNonePropagationScope (for asynchronous functions).
Note that the unwrapOrPropagateNone method allows chaining, for example: return Ok(a.unwrapOrPropagateNone(t).makeCall().unwrapOrPropagateNone(t).makeSecondCall().unwrapOrPropagateNone(t)), where makeCall and makeSecondCall must be methods defined in T returning Option<T>.
Nested options and comparison to nullable types
A big difference between Option types and nullable types is that Option types can be nested. For example: both
None() and Some(None()) are valid values for Option<Option<int>>.
On the other hand, with nullable types some structures are just not possible. For example, the type
int?? is not something similar to Option<Option<int>>; on the contrary, is exactly the same as int?.
Thus, the distinction between None() and Some(None()) is just not possible to do with null.
Nested options are mostly useful e.g. when we do a find in a list of Options.
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')
Parallel to Rust's try-operator implementation
With Result types, a parallel to Rust's try-operator is achieved combining unwrapOrPropagateErr with syncErrPropagationScope (for synchronous functions) or asyncErrPropagationScope (for asynchronous ones). For those unfamiliar with Rust, the try operator tries to unwrap the Result value, however, if the Result is a None, the try operator propagates Err (this way we don't have to always write code for checking for and possibly returning the Err; the more potential Err cases there are, the more pragmatic this pattern becomes).
// Example from Result.syncErrPropagationScope docstring
Result<double, String> example2(Result<double, String> r) {
var s = r.map(identity); // initial `s`: a copy of `r`
return Result.syncErrPropagationScope((t) {
s = Ok(s.unwrapOrPropagateErr(t) / 2); // it will propagate now if initial `s` was Err
s = Err('not propagating yet');
s.unwrapOrPropagateErr(t); // now it will propagate now if initial `s` was Ok
s = Ok(s.unwrapOrPropagateErr(t) / 0); // dead code (not shown by IDE)
return Ok(s.unwrapOrPropagateErr(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');
}
ErrPropagationToken features a private constructor, thus l.unwrapOrPropagateErr(ErrPropagationToken()) cannot be used to pass the required argument (and thus execute the method).
The provided argument t in the syncErrPropagationScope callback is an instance of ErrPropagationToken, and is expected to be passed to unwrapOrPropagateErr. The propagation that is thrown inside the fn argument of syncErrPropagationScope must be handled by syncErrPropagationScope's body. If there is no syncErrPropagationScope, then there cannot be any a ErrPropagationToken t, making l.unwrapOrPropagateErr impossible to invoke. Therefore, the ErrPropagationToken guarantees compile-time safety.
The same holds for asyncErrPropagationScope (for asynchronous functions).
Note that the unwrapOrPropagateErr method allows chaining, for example: return Some(a.unwrapOrPropagateErr(t).makeCall().unwrapOrPropagateErr(t).makeSecondCall().unwrapOrPropagateErr(t)), where makeCall and makeSecondCall must be methods defined in T returning Result<T, E>.
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 asyncErrPropagationScope 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.asyncErrPropagationScope((t) async {
(await failableOperation1()).unwrapOrPropagateErr(t);
failableOperation2().unwrapOrPropagateErr(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.(a)syncErrPropagationScope or Option.(a)syncNonePropagationScope can
be done, however we just need to ensure NonePropagation and ErrPropagation are handled only inside
Result.(a)syncErrPropagationScope/Option.(a)syncNonePropagationScope.
If the try block wraps the syncScope/asyncScope function and there is no outer syncScope/asyncScope
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.syncErrPropagationScope((t) {
s = Ok(s.unwrapOrPropagateErr(t) / 2); // it will propagate now if initial `s` was Err
throw 'example';
s = Err('not propagating yet'); // dead code
s.unwrapOrPropagateErr(t);
s = Ok(s.unwrapOrPropagateErr(t) / 0);
return Ok(s.unwrapOrPropagateErr(t));
});
} on String {
return Err('caught a String');
}
}
However, a try-catch inside the syncScope/asyncScope's callback or any function it calls then we must be a little careful.
Bad example
The next example catches also ErrPropagation<String> (that is thrown by unwrapOrPropagateErr if s is an Err),
which compromises the error propagation.
Result<double, String> badExample(Result<double, String> r) {
var s = r.map(identity);
return Result.syncErrPropagationScope<double, String>((t) {
try {
s = Ok(s.unwrapOrPropagateErr(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.syncErrPropagationScope<double, String>((t) {
try {
s = Ok(s.unwrapOrPropagateErr(t) / [1,2,3].elementAt(100));
} on RangeError catch (e) {
s = Err('index too high');
}
return Ok(s.unwrapOrPropagateErr(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 always rethrow Propagation:
Result<double, String> goodExample2(Result<double, String> r) {
var s = r.map(identity);
return Result.syncErrPropagationScope<double, String>((t) {
try {
s = Ok(s.unwrapOrPropagateErr(t) / [1,2,3].elementAt(100));
} on Propagation {
rethrow; // always rethrow so that the contained error propagates
} catch (e) {
s = Err('index too high');
}
return Ok(s.unwrapOrPropagateErr(t));
});
}
Key differences from Rust #
OptionandResulttypes 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.- This library lacks all of the methods Rust's
OptionandResulttypes have that are related toref,deref,mut,pin,clone, andcopydue 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
OptionandResultmethodsmapOr,mapOrElsereturnOption<U>andResult<U, E>respectively to aid composition ofOptionandResultvalues. The encapsulated values of these types should never leave the context ofOptionorResultunless 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.