nidula 2.0.1 copy "nidula: ^2.0.1" to clipboard
nidula: ^2.0.1 copied to clipboard

A Dart library for Rust-like Option/Result types, providing a structured approach to error handling.

nidula #

nidula is a lightweight library bringing Rust's Option and Result types to Dart.

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 #

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.

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. Chaining methods #

An ergonomic approach to use option types 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.2. 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.

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. Chaining methods #

An ergonomic approach to use result types 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.2. 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

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. 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.

5. 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.

6. History #

nidula is a fork of option_result bringing numerous enhancements:

  • 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.
  • Removed the parallel to Rust's try-operator implementation.
    • This simplifies the API.
    • A custom try operator used only by this library would not fit with the rest of the Dart/Flutter ecosystem; it would just make this library very confusing.
  • 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) {}.
7
likes
160
points
216
downloads

Publisher

verified publishermanuelplavsic.ch

Weekly Downloads

2024.08.09 - 2025.02.21

A Dart library for Rust-like Option/Result types, providing a structured approach to error handling.

Repository
View/report issues

Topics

#option #result #pattern-matching

Documentation

API reference

License

MIT (license)

More

Packages that depend on nidula