nidula 0.0.2 copy "nidula: ^0.0.2" to clipboard
nidula: ^0.0.2 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 and E types must extend Object (thus, non-null values are prohibited).
    • This enforces composition with Option types (Option<T>) instead of nullable types (T?).
  • Only T v and E e fields (and, thus, getters) are available.
    • value, val, err and error aliases were removed.
  • There is only a single public library to import components from.
  • Final modifiers to prevent extending Ok, Err, Some and None.
  • == operator takes all 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. (v) {} instead of (p0) {}.

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.

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.
    The benefits of immutability speak for themselves, but this also allows compile-time const Option and Result values which can help improve application performance.

  • 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 been renamed where() to be more Dart-idiomatic.

  • The Option and Result methods mapOr, mapOrElse return Option<U> and Result<U, E> respectively to aid composition of Option and Result values. The encapsulated values of these types should never leave the context of Option or Result 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.
6
likes
0
pub points
5%
popularity

Publisher

verified publishermanuelplavsic.ch

A lightweight Dart library for Rust-like Option/Result types. Supports exhaustive pattern matching and compile-time safe, chainable None/Err propagation.

Repository (GitLab)
View/report issues

Topics

#option #result #pattern-matching #try-operator

License

unknown (license)

More

Packages that depend on nidula