nidula

Last updated:

0 purchases

nidula Image
nidula Images
Add to Cart

Description:

nidula

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.
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.). Dart 3's exhaustive pattern matching can be easily leveraged thanks to the provided snippets (see the Pattern matching snippets (VSCode) section).
Contents:

1. Option

1.1. Parallel to Rust's try operator
1.2. Chaining methods
1.3. Comparison with nullable types


2. Result

2.1. Parallel to Rust's try operator
2.2. Chaining methods
2.3. Unit type


3. Pattern matching snippets (VSCode)

3.1. Configuration


4. Warning: None/Err propagation with try-catch blocks
5. Error conversion before propagation

5.1. Extensions for common conversions


6. Nest types
7. Key differences from Rust
8. History

1. 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.
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);

// NB: this is a curried function
// example: `divideBy(2)(8)` results in `Some(8 ~/ 2)`, i.e., `Some(4)`
Option<int> Function(int dividend) Function(int divisor) divideBy =
(int divisor) => (int dividend) => switch (divisor) {
0 => None(),
_ => Some(dividend ~/ divisor),
};

void main() {
Option<int> a = Some(10);
Option<int> b = Some(0);
Option<int> c = None();

Option<int> d = a.andThen(divideBy(2)).andThen(multiplyBy5); // Some(25)
Option<int> e = a.andThen(divideBy(0)).andThen(multiplyBy5); // None()

Option<int> f = b.andThen(divideBy(2)).andThen(multiplyBy5); // Some(0)
Option<int> g = b.andThen(divideBy(0)).andThen(multiplyBy5); // None()

Option<int> h = c.andThen(divideBy(2)).andThen(multiplyBy5); // None()
Option<int> i = c.andThen(divideBy(0)).andThen(multiplyBy5); // None()
}
copied to clipboard
1.1. Parallel to Rust's try-operator #
With Option types, a parallel to Rust's try-operator is achieved combining try_ with tryScope (for synchronous functions) or asyncTryScope (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:
// Example from Option.tryScope docstring
Option<int> example2(Option<int> l) {
return Option.tryScope<int>((nt) {
l = Some(l.try_(nt) + [1, 2, 3].elementAt(1));
// it will propagate now if initial `l` was None, else continues
l = None(); // not propagating yet
l.try_(nt); // it will propagate now if initial `l` was Some
l = Some(l.try_(nt) + [5, 6].elementAt(1)); // dead code (not detected by IDE)
return Some(l.try_(nt));
});
}

Option<int> myOption = example(Some(9));

switch (myOption) {
case Some(:int v): print('Contained value: $v');
case None(): print('None');
}
copied to clipboard
NonePropagationToken features a private constructor, thus l.try_(NonePropagationToken()) cannot be used to pass the required argument (and thus execute the method).
The provided argument nt in the tryScope callback is an instance of NonePropagationToken, and is expected to be passed to try_. The propagation that is thrown inside the fn argument of tryScope must be handled by tryScope's body. If there is no tryScope, then there cannot be any a NonePropagationToken nt, making l.try_ impossible to invoke. Therefore, the NonePropagationToken guarantees compile-time safety.
The same holds for asyncTryScope.
Note that the try_ method allows chaining, for example: return Ok(a.try_(nt).makeCall().try_(nt).makeSecondCall().try_(nt)), where makeCall and makeSecondCall must be methods defined in T returning Option<T>.
1.1.1. NonePropagation instances
NonePropagations are Dart errors thrown with StackTrace.empty when calling try_(et) on options that are None, and are supposed to be handled solely by this library with the provided helpers.
1.2. Chaining methods #
Instead of employing try_ for None propagation, an alternative approach involves leveraging asynchronous chaining methods on Option<T> and Future<Option<T>>.

methods that return another option:

onSome
onNone
onAny


method that returns a generic type of your choice:

chain



Option<T> also has synchronous chaining methods onSomeSync, onNoneSync, onAnySync and chainSync.
All these methods align with functional programming principles.
1.3. Comparison with nullable types #
A big difference between Option types and nullable types (e.g. int?) 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? (i.e. int?? = 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.
2. 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);

// NB: this is a curried function
// example: `divideBy(2)(8)` results in `Ok(8 ~/ 2)`, i.e., `Ok(4)`
Result<int, String> Function(int dividend) Function(int divisor) divideBy =
(int divisor) => (int dividend) => switch (divisor) {
0 => Err('divided by 0'),
_ => Ok(dividend ~/ divisor),
};

void main() {
Result<int, String> a = Ok(10);
Result<int, String> b = Ok(0);
Result<int, String> c = Err('foo');

Result<int, String> d = a.andThen(divideBy(2)).andThen(multiplyBy5); // Ok(25)
Result<int, String> e = a.andThen(divideBy(0)).andThen(multiplyBy5); // Err(divided by 0)

Result<int, String> f = b.andThen(divideBy(2)).andThen(multiplyBy5); // Some(0)
Result<int, String> g = b.andThen(divideBy(0)).andThen(multiplyBy5); // Err(divided by 0)

Result<int, String> h = c.andThen(divideBy(2)).andThen(multiplyBy5); // Err(foo)
Result<int, String> i = c.andThen(divideBy(0)).andThen(multiplyBy5); // Err(foo)
}
copied to clipboard
2.1. Parallel to Rust's try-operator #
With Result types, a parallel to Rust's try-operator is achieved combining try_ with tryScope (for synchronous functions) or asyncTryScope (for asynchronous ones). For those unfamiliar with Rust, the try operator tries to unwrap the Result value, however, if the Result is an Err, 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:
// Example from Result.tryScope docstring
Result<double, String> example2(Result<double, String> s) {
return Result.tryScope((et) {
s = Ok(s.try_(et) / 2); // it will propagate now if initial `s` was Err
s = Err('not propagating yet');
s.try_(et); // it will propagate now if initial `s` was Ok
s = Ok(s.try_(et) / 0); // dead code (not detected by IDE)
return Ok(s.try_(et));
});
}

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');
}
copied to clipboard
ErrPropagationToken features a private constructor, thus l.try_(ErrPropagationToken()) cannot be used to pass the required argument (and thus execute the method).
The provided argument et in the tryScope callback is an instance of ErrPropagationToken, and is expected to be passed to try_. The propagation that is thrown inside the fn argument of tryScope must be handled by tryScope's body. If there is no tryScope, then there cannot be any a ErrPropagationToken et, making l.try_ impossible to invoke. Therefore, the ErrPropagationToken guarantees compile-time safety.
The same holds for asyncTryScope.
Note that the try_ method allows chaining, for example: return Some(a.try_(et).makeCall().try_(et).makeSecondCall().try_(et)), where makeCall and makeSecondCall must be methods defined in T returning Result<T, E>.
2.1.1. ErrPropagation instances
ErrPropagation<E>s are Dart errors thrown with StackTrace.empty when calling try_(et) on results that are Err, and are supposed to be handled solely by this library with the provided helpers.
2.2. Chaining methods #
Instead of employing try_ for Err propagation, an alternative approach involves leveraging asynchronous chaining methods on Result<T, E> and Future<Result<T, E>>:

methods that return another result:

onOk
onErr
onAny


method that returns a generic type of your choice:

chain



Result<T, E> also has synchronous chaining methods onOkSync, onErrSync, onAnySync and chainSync.
All these methods align with functional programming principles.
2.3. Unit type #
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...
copied to clipboard
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 asyncTryScope 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.asyncTryScope((et) async {
(await failableOperation1()).try_(et);
failableOperation2().try_(et);

return Ok(());
});

if (err case Err(e: String error)) {
print(error);
return;
}

// No error, continue...
copied to clipboard
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.
3. Pattern matching snippets (VSCode) #
Doing pattern matching is slightly verbose, and the repetitive task can be time-consuming and/or error-prone. This section presents some VSCode snippets that make interacting with options and results a lot faster.
3.1. Configuration #
Select Snippets: Configure User Snippets, then choose New Global Snippets file... and give a name (e.g. nidula).
Delete everything that is inside the generated file, and then paste:
{
"Option pattern matching expression": {
"scope": "dart",
"prefix": "match option expression",
"body": [
"final ${1:_} = switch (${2:option}) {"
" None() => $0,"
" Some(v: final ${3:v}) => ,"
"};",
],
"description": "Pattern matching expression snippet for Opiton values.",
},
"Option pattern matching statement": {
"scope": "dart",
"prefix": "match option statement",
"body": [
"switch (${1:option}) {"
" case None():"
" $0;"
" case Some(v: final ${2:v}):"
" ;"
"}",
],
"description": "Pattern matching statement snippet for Option values.",
},
"Result pattern matching expression": {
"scope": "dart",
"prefix": "match result expression",
"body": [
"final ${1:_} = switch (${2:result}) {"
" Err(e: final ${3:e}) => $0,"
" Ok(v: final ${4:v}) => ,"
"};",
],
"description": "Pattern matching expression snippet for Result values.",
},
"Result pattern matching statement": {
"scope": "dart",
"prefix": "match result statement",
"body": [
"switch (${1:result}) {"
" case Err(e: final ${2:e}):"
" $0;"
" case Ok(v: final ${3:v}):"
" ;"
"}",
],
"description": "Pattern matching statement snippet for Result values.",
},
"Option case Some and value": {
"scope": "dart",
"prefix": "if option case Some(v: T v)",
"body": [
"if (${1:option} case Some(v: final ${2:v})) {"
" $0"
"}",
],
"description": "Option case Some and its value.",
},
"Result case Err and value": {
"scope": "dart",
"prefix": "if result case Err(e: E e)",
"body": [
"if (${1:result} case Err(e: final ${2:e})) {"
" $0"
"}",
],
"description": "Result case Err and its value.",
},
"Result case Ok and value": {
"scope": "dart",
"prefix": "if result case Ok(v: T v)",
"body": [
"if (${1:result} case Ok(v: final ${2:v})) {"
" $0"
"}",
],
"description": "Result case Ok and its value.",
},
}
copied to clipboard
Now, every time e.g. result or option (note the extra empty space in both cases) are typed in a Dart file, IDE autocomplete will suggest the snippets above. Use the tab key to go to the next placeholder (in case of autocomplete suggestion, press the escape key before switching placeholders with the tab key).
4. Warning: None/Err propagation with try-catch blocks #
Using try-catch in combination with try_ and tryScope/asyncTryScope can
be done, however we need to ensure NonePropagation and ErrPropagation are handled only inside
tryScope/asyncTryScope.
If the try block wraps the tryScope/asyncTryScope function and there is no outer tryScope/asyncTryScope
wrapping the try-catch block, then it is fine. For example:
Result<double, String> example3(Result<double, String> s) {
try {
return Result.tryScope((et) {
s = Ok(s.try_(et) / 2); // it will propagate now if initial `s` was Err
throw 'example';
s = Err('not propagating yet'); // dead code
s.try_(et);
s = Ok(s.try_(et) / 0);
return Ok(s.try_(et));
});
} on String {
return Err('caught a String');
}
}
copied to clipboard
However, we must be a little careful with a try-catch inside the tryScope/asyncTryScope's callback or any function that is called inside of it.
Bad example
The next example catches also ErrPropagation<String> (that is thrown by try_ if s is an Err),
which compromises the error propagation.
Result<double, String> badExample(Result<double, String> s) {
return Result.tryScope<double, String>((et) {
try {
s = Ok(s.try_(et) / [1,2,3].elementAt(100));
} catch (e) {
s = Err('index too high');
}
return Ok(s.try_(et));
});
}
copied to clipboard
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> s) {
return Result.tryScope<double, String>((et) {
try {
s = Ok(s.try_(et) / [1,2,3].elementAt(100));
} on RangeError catch (e) {
s = Err('index too high');
}
return Ok(s.try_(et));
});
}
copied to clipboard
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> s) {
return Result.tryScope<double, String>((et) {
try {
s = Ok(s.try_(et) / [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.try_(et));
});
}
copied to clipboard
5. Error conversion before propagation #
In Rust, it is possible to covert errors leveraging the From trait. With Dart, there is not
an equivalent.
The recommended solution is to use mapErr every time.
5.1. Extensions for common conversions #
In case using mapErr might inflate the verbosity of the code, extensions containing conversions can be defined
in your application codebase. For each conversion we need to define a new
method, e.g., tryCvtBool, which converts the error contained in Err<(), int>
(which is of int type) to a bool before propagating it.
Example:
extension CvtResultIntErr<T> on Result<T, int> {
T tryCvtString(ErrPropagationToken<String> et) {
return mapErr((errE) => '$errE').try_(et);
}

T tryCvtBool(ErrPropagationToken<bool> et) {
return mapErr((errE) => errE == 1).try_(et);
}
}

extension CvtResultBoolErr<T> on Result<T, bool> {
T tryCvtString(ErrPropagationToken<String> et) {
return mapErr((errE) => '$errE').try_(et);
}
}

void a() {
final Result<(), int> ok = Ok(());
final a = Result.tryScope<(), int>((et) {
return Ok(ok.try_(et));
});
final b = Result.tryScope<(), String>((et) {
return Ok(ok.tryCvtString(et));
});
final c = Result.tryScope<(), bool>((et) {
return Ok(ok.tryCvtBool(et));
});

final Result<(), bool> ok2 = Ok(());
final a2 = Result.tryScope<(), bool>((et) {
return Ok(ok2.try_(et));
});
final b2 = Result.tryScope<(), String>((et) {
return Ok(ok2.tryCvtString(et));
});
}
copied to clipboard
6. Nest types #
Nest is a generic wrapper class that circumnavigates the limitation of nullable types.
Some libraries explicitly expect some parameters to be of type T?, usually
because null means the type was not initialized yet. Think of
a previous value of an observable which still holds its initial value.
If T is nullable (e.g. int?), then it will be unclear whether null
means that the previous state was actually null, or whether there was
no previous state. This is because nullable types cannot be nested, i.e. T?? == T?. Therefore, int?? is the same as int? in the given example.
Instead of using the type T directly, we can wrap T with a [Nest]. In the example above, we can replace the generic type int? with Nest<int?>. The above previous getter/field has now type Nest<int?>?, which makes it trivial to distinguish between present null value and absent value.
7. Key differences from Rust #

Option and Result 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.

This library lacks all of the methods Rust's Option and Result types have
that are related to ref, deref, mut, pin, clone, and copy due to not
being applicable to Dart as a higher-level language.

The Option.filter
method has a Dart-idiomatic Option.where alias.

Added methods Option.match and Result.match, as they are more
pragmatic for simple computations or side effects than Dart's built-in pattern matching. However:

Dart's pattern matching switch expressions on Result values are more powerful than these match methods.
Dart's pattern matching switch statements on Result values allow to "early" return, unlike these match methods.



Added methods Result.okToNullable and Result.errToNullable.

Added chaining methods such as onSome, onOk, chain, ...

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 easily managed via the provided helpers.

8. History #
nidula is a fork of option_result bringing numerous enhancements:

Parallel to Rust's try-operator implementation rewritten from scratch.

Simple library-internal error handling strategy.
Efficient, as no stacktraces re used for library internal propagations.


The return type for Option.mapOr, Option.mapOrElse, Result.mapOr and Result.mapOrElse is only U in nidula.
Added toJson and fromJson support.
Added Result.okToNullable, and Result.errToNullable.
Added zipOp.
Only T v and E e fields are available.

value, val, err and error aliases (getters) were removed.


There is only a single public library to import components from.
Final modifiers to prevent extending Ok, Err, Some and None.

Correctly exhaustive pattern matching support, IDE-wise.


== operator takes also generic types into consideration when comparing Option objects
and Result objects.
Added variable names to all function parameters in types.

Callback autocomplete outputs e.g. (okV) {} instead of (p0) {}.

License:

For personal and professional use. You cannot resell or redistribute these repositories in their original state.

Files In This Product:

Customer Reviews

There are no reviews.