Danom provides Option and Result types for C#, inspired by F#. It’s designed to be easy to use, efficient, and compatible with existing C# codebases. These types offer a type-safe way to represent nullable values and expected errors, while also supporting a fluent API (e.g., map, bind) for chaining operations and value transformations.
- Implementation of common monads: Option and Result.
- Exhaustive matching to prevent null reference exceptions.
- Fluent API for chaining operations, including async support.
- Built-in error handling with ResultErrors.
- An API for parsing strings into .NET primitives and value types.
- Input validation via Danom.Validation.
- Integration with ASP.NET MVC and ASP.NET Minimal API.
- Provide a safe and expressive way to handle nullable values.
- Efficient implementation to minimize overhead.
- Enforce exhaustive matching.
- Enhance functional programming in C#.
- Opionated monads to encourage consistent use.
- Support for both synchronous and asynchronous operations.
netstandard2.1
compatible.
Install the Danom NuGet package:
dotnet add package Danom
OR
PM> Install-Package Danom
- Get started with Danom.Validation.
- Get started with Danom.MinimalApi.
- Get started with Danom.Mvc.
using Danom;
// Options
Option.Some(5)
.Map(x => x * 2)
// ^--- transform the value
.Bind(x => x > 5 ? Option.Some(x) : Option<int>.None())
// ^--- chain another operation that returns an Option
.Match(
some: x => Console.WriteLine("Value: {0}", x),
none: () => Console.WriteLine("No value"));
// Results
public Result<int, string> TryDivide(int numerator, int denominator) =>
denominator == 0
? Result<int, string>.Error("Cannot divide by zero")
: Result<int, string>.Ok(numerator / denominator);
TryDivide(10, 2)
.Map(x => x + 1)
// ^--- transform the value
.Bind(x => TryDivide(x, 0))
// ^--- chain another operation that returns a Result
.MapError(e => $"Error occurred: {e}")
// ^--- transform the error
.Match(
ok: x => Console.WriteLine("Result: {0}", x),
error: e => Console.WriteLine("Error: {0}", e));
Options have an underlying type and can optionally hold a value of that type. Options are a much safer way to handle nullable values, they virtually eliminate null reference exceptions. They also provide a fantastic means of reducing primitive congestion in your code.
var option = Option<int>.Some(5);
// or, with type inference
var optionInferred = Option.Some(5);
// or, with no value
var optionNone = Option<int>.NoneValue;
// also returns none
var optionNull = Option<object>.Some(default!);
Options are commonly used when a operation might not return a value. For example, the method below tries to find a number in a list that satisfies a predicate. If the number is found, it is returned as a Some
, otherwise, None
is returned.
using Danom;
public Option<int> TryFind(IEnumerable<int> numbers, Func<int, bool> predicate) =>
numbers.FirstOrDefault(predicate).ToOption();
With this method defined we can begin performing operations against the Option result:
using Danom;
IEnumerable<int> nums = [1,2,3];
// Exhaustive matching
TryFind(nums, x => x == 1)
.Match(
some: x => Console.WriteLine("Found: {0}", x),
none: () => Console.WriteLine("Did not find number"));
// Mapping the value (i.e., I want to access the value)
Option<int> optionSum =
TryFind(nums, x => x == 1)
.Map(x => x + 1);
// Binding the option (i.e., when a nested operation also returns an Option)
Option<int> optionBindSum =
TryFind(nums, x => x == 1)
.Bind(num1 =>
TryFind(nums, x => x == 2)
.Map(num2 => num1 + num2));
// Handling "None"
Option<int> optionDefault =
TryFind(nums, x => x == 4)
.DefaultValue(99);
Option<int> optionDefaultWith =
TryFind(nums, x => x == 4)
.DefaultWith(() => 99); // useful if creating the value is expensive
Option<int> optionOrElse =
TryFind(nums, x => x == 4)
.OrElse(Option<int>.Some(99));
Option<int> optionOrElseWith =
TryFind(nums, x => x == 4)
.OrElseWith(() => Option<int>.Some(99)); // useful if creating the value is expensive
Results are used to represent a success or failure outcome. They provide a more concrete way to manage the expected errors of an operation, then throwing exceptions. Especially in recoverable or reportable scenarios.
using Danom;
var result = Result<int, string>.Ok(5);
// or, with an error
var resultError = Result<int, string>.Error("An error occurred");
Results are commonly used when an operation might not succeed, and you want to manage or report back the expected errors. For example:
Let's create a simple inline function to divide two numbers. If the denominator is zero, we want to return an error message.
using Danom;
Result<int, string> TryDivide(int numerator, int denominator) =>
denominator == 0
? Result<int, string>.Error("Cannot divide by zero")
: Result<int, string>.Ok(numerator / denominator);
With this method defined we can begin performing operations against the result:
using Danom;
// Exhaustive matching
TryDivide(10, 2)
.Match(
ok: x => Console.WriteLine("Result: {0}", x),
error: e => Console.WriteLine("Error: {0}", e));
// Mapping the value
Result<int, string> resultSum =
TryDivide(10, 2)
.Map(x => x + 1);
// Binding the result (i.e., when a nested operation also returns a Result)
Result<int, string> resultBindSum =
TryDivide(10, 2)
.Bind(num1 =>
TryDivide(20, 2)
.Map(num2 =>
num1 + num2));
// Handling errors
Result<int, string> resultDefault =
TryDivide(10, 0)
.DefaultValue(99);
Result<int, string> resultDefaultWith =
TryDivide(10, 0)
.DefaultWith(() => 99); // useful if creating the value is expensive
Result<int, string> resultOrElse =
TryDivide(10, 0)
.OrElse(Result<int, string>.Ok(99));
Result<int, string> resultOrElseWith =
TryDivide(10, 0)
.OrElseWith(() =>
Result<int, string>.Ok(99)); // useful if creating the value is expensive
Danom provides a built-in error type, ResultErrors
, to simplify the creation of results with multiple errors. This type can be initialized with a single string, a collection of strings, or a key-value pair. It can be thought of as a domain-specific dictionary of string keys and N string values.
using Danom;
var resultOk = Result.Ok(5); // or, Result<int>.Ok(5);
var resultErrors =
Result<int>.Error("An error occurred");
var resultErrorsMultiError =
Result<int>.Error(["An error occurred", "Another error occurred"]);
var resultErrorsKeyed =
Result<int>.Error("error-key", "An error occurred");
var resultErrorsKeyedMulti =
Result<int>.Error("error-key", ["An error occurred", "Another error occurred"]);
Danom provides a Unit
type to represent the absence of a value. This type is useful in functional programming as a way to represent the absence of a meaningful value. It acts as a placeholder when a function needs to return something, but there is no actual data to return—similar to void
in C#, but as a real type that can be used in generic code, composed in monads like Option
and Result
, or passed as a value. This enables more consistent and expressive APIs, especially when working with functional patterns, pipelines, or asynchronous workflows where a type is always required.
using Danom;
void Log(string message) => Console.WriteLine(message);
// Convert an Action to a Func that returns Unit
Func<string, Unit> logFunc = Log.ToUnitFunc();
// Use in a functional pipeline
Option<string> maybeMessage = Option.Some("Hello, world!");
maybeMessage
// Logs the message and returns Option<Unit>
.Map(logFunc);
Inevitably you'll need to interact with these functional types in a procedural way. Both Option and Result provide a TryGet
method to retrieve the underlying value. This method will return a bool
indicating whether the value was successfully retrieved and the value itself as an output parameter.
using Danom;
var option = Option<int>.Some(5);
if (option.TryGet(out var value)) {
Console.WriteLine("Value: {0}", value);
}
else {
Console.WriteLine("No value");
}
using Danom;
var result = Result<int, string>.Ok(5);
if (result.TryGet(out var value)) {
Console.WriteLine("Result: {0}", value);
}
else if (result.TryGetError(out var error)) {
Console.WriteLine("Error: {0}", error);
}
else {
Console.WriteLine("No value or error");
}
Most applications will at some point need to parse strings into primitives and value types. This is especially true when working with external data sources.
Option
provides a natural mechanism to handle the case where the string cannot be parsed. The "TryParse" API is provided to simplify the process of parsing strings into .NET primitives and value types.
using Danom;
// a common pattern
var x = int.TryParse("123", out var y) ? Option<int>.Some(y) : Option<int>.NoneValue;
// or, more simply using the TryParse API
var myInt = intOption.TryParse("123"); // -> Some(123)
var myDouble = doubleOption.TryParse("123.45"); // -> Some(123.45)
var myBool = boolOption.TryParse("true"); // -> Some(true)
// if the string cannot be parsed
var myIntNone = intOption.TryParse("danom"); // -> None
var myDoubleNone = doubleOption.TryParse("danom"); // -> None
var myBoolNone = boolOption.TryParse("danom"); // -> None
// null strings are treated as None
var myIntNull = intOption.TryParse(null); // -> None
The full API is below:
public static class boolOption {
public static Option<bool> TryParse(string? x); }
public static class byteOption {
public static Option<byte> TryParse(string? x, IFormatProvider? provider = null); }
public static class shortOption {
public static Option<short> TryParse(string? x, IFormatProvider? provider = null);
public static Option<short> TryParse(string? x); }
public static class intOption {
public static Option<int> TryParse(string? x, IFormatProvider? provider = null);
public static Option<int> TryParse(string? x); }
public static class longOption {
public static Option<long> TryParse(string? x, IFormatProvider? provider = null);
public static Option<long> TryParse(string? x); }
public static class decimalOption {
public static Option<decimal> TryParse(string? x, IFormatProvider? provider = null);
public static Option<decimal> TryParse(string? x); }
public static class doubleOption {
public static Option<double> TryParse(string? x, IFormatProvider? provider = null);
public static Option<double> TryParse(string? x); }
public static class floatOption {
public static Option<float> TryParse(string? x, IFormatProvider? provider = null);
public static Option<float> TryParse(string? x); }
public static class GuidOption {
public static Option<Guid> TryParse(string? x, IFormatProvider? provider = null);
public static Option<Guid> TryParse(string? x);
public static Option<Guid> TryParseExact(string? x, string? format); }
public static class DateTimeOffsetOption {
public static Option<DateTimeOffset> TryParse(string? x, IFormatProvider? provider = null);
public static Option<DateTimeOffset> TryParse(string? x);
public static Option<DateTimeOffset> TryParseExact(string? x, string? format, IFormatProvider? provider = null, DateTimeStyles dateTimeStyles = DateTimeStyles.None); }
public static class DateTimeOption {
public static Option<DateTime> TryParse(string? x, IFormatProvider? provider = null);
public static Option<DateTime> TryParse(string? x);
public static Option<DateTime> TryParseExact(string? x, string? format, IFormatProvider? provider = null, DateTimeStyles dateTimeStyles = DateTimeStyles.None); }
public static class DateOnlyOption {
public static Option<DateOnly> TryParse(string? x, IFormatProvider? provider = null);
public static Option<DateOnly> TryParse(string? x);
public static Option<DateOnly> TryParseExact(string? x, string? format, IFormatProvider? provider = null, DateTimeStyles dateTimeStyles = DateTimeStyles.None); }
public static class TimeOnlyOption {
public static Option<TimeOnly> TryParse(string? x, IFormatProvider? provider = null);
public static Option<TimeOnly> TryParse(string? x);
public static Option<TimeOnly> TryParseExact(string? x, string? format, IFormatProvider? provider = null, DateTimeStyles dateTimeStyles = DateTimeStyles.None); }
public static class TimeSpanOption {
public static Option<TimeSpan> TryParse(string? x, IFormatProvider? provider = null);
public static Option<TimeSpan> TryParse(string? x);
public static Option<TimeSpan> TryParseExact(string? x, string? format, IFormatProvider? provider = null); }
public static class EnumOption {
public static Option<TEnum> TryParse<TEnum>(string? x) where TEnum : struct; }
One of the places the Result
type really shines is input validation. It's a natural step in most workflows to validate input data before processing it, and the Result
type is a great way to handle this. The Danom.Validation library provides an API for defining validation rules and checking input data against those rules, returning a Result<T, ResultErrors>
that contains either the validated data or an error message.
Documentation can be found here.
Since Danom introduces types that are most commonly found in your model and business logic layers, external integrations are not only inevitable but required to provide a seamless experience when building applications.
These are completely optional, but provide a great way to integrate Danom with your codebase.
Danom is integrated with ASP.NET Core MVC (and Razor Pages) via Danom.Mvc. This library provides a set of utilities to help integrate the core types with common tasks in ASP.NET Core MVC applications.
Danom is integrated with ASP.NET Core Minimal API via Danom.MinimalApi. This library provides a set of utilities to help integrate the core types with common tasks in ASP.NET Core Minimal API applications.
There's an issue for that.
Licensed under MIT.