FunicularSwitch is a lightweight C# port of F# result and option types.
FunicularSwitch helps you to:
- Focus on the 'happy path', but collect all error information.
- Be more explicit in what our methods return.
- Avoid deep nesting.
- Avoid null checks and eventual properties (properties only relevant for a certain state of an object), use Result or Option instead.
- Comfortably write async code pipelines.
- Wrap third party library exceptions / return values into results at the code level were we really understand what is happening.
FunicularSwitch is a library containing the Result and Option type. Usage and the general idea is described in the following sections. The 'Error' type is always string, which allows natural concatenation and is sufficient in many cases.
FunicularSwitch.Generators is a C# source generator package (projects consuming it, will have no runtime dependency to any FunicularSwitch dll). With this source generator you can have a result type with the very same behaviour as FunicularSwitch.Result but a custom error type (instead of string) by just annotating a class with the ResultType
attribute. That means you are free to represent failures in a way suitable for your needs. A second thing coming with this package are generated F#-like Match methods. They allow for compiler safe switches handling all concrete subtypes of a base class (very useful for union type implementations). As a third thing the same Match methods are also generated for enum types annotated with the ExtendedEnum
attribute.
This document is created using dotnet try. If you have dotnet try global tool installed, just clone the repo, type dotnet try
on top level and play around with all code samples in your browser while reading.
This following section mainly focuses on Result
. Result
is a union type representing either Ok or the Error case just like F#s Result type. For FunicularSwitch the error type is String
for sake of simplicity (Using types with multiple generic arguments is quite verbose in C#).
Result should be used in all places, were something can go wrong. Doing so it replaces exceptions and null/default return values.
Creating a Result
is easy:
//Ok result:
var fortyTwo = Result.Ok(42);
//or using implicit cast operator
Result<string> ok = "Ok";
//Error result:
var error = Result.Error<int>("Could not find the answer");
Now lets follow the happy path, do something, if everything was ok. Map
:
static Result<int> Ask() => 42;
Result<int> answerTransformed = Ask()
.Map(answer => answer * 2);
Console.WriteLine(answerTransformed);
Ok 84
or do something that might fail, if everything was ok. Bind
:
static Result<int> Ask() => 42;
Result<int> answerTransformed = Ask()
.Bind(answer => answer == 0 ? Result.Error<int>("Division by zero") : 42 / answer);
Console.WriteLine(answerTransformed);
Ok 1
The lambdas passed to Map
and Bind
are only invoked if everything went well so far, otherwise you are on the error track were error information is passed on 'invisibly':
b
static Result<int> Transform(Result<int> result) =>
result
.Bind(answer => answer == 0 ? Result.Error<int>("Division by zero") : 42 / answer)
.Map(transformed => transformed * 2);
Result<int> firstLevelError = Transform(Result.Error<int>("I don't know"));
Console.WriteLine($"First level: {firstLevelError}");
Result<int> secondLevelError = Transform(Result.Ok(0));
Console.WriteLine($"Second level: {secondLevelError}");
First level: Error I don't know
Second level: Error Division by zero
Finally you might want to leave the Result
world, so you have to take care of the error case as well (that's a good thing!). Match
:
static Result<int> Ask() => 42;
string whatIsIt =
Ask().Match(
answer => $"The answer is: {answer}",
error => $"Ups: {error}"
);
Console.WriteLine(whatIsIt);
The answer is: 42
Those are basically the four (actually three) main operations on Result
- Create
, Bind
, Map
and Match
. There are a lot of overloads and other helpers in FunicularSwitch to avoid repetition of Result
specific patterns like:
- 'Combine results to Ok if everything is Ok otherwise collect errors' -
Aggregate
,Map
andBind
overloads on collections - 'Ok if at least one item passes certain validations, otherwise collect info why no one matched' -
FirstOk
- 'Ok if item from a dictionary was found, otherwise (nice) error' -
TryGetValue
extension on Dictionary - 'Ok if type T is
as
convertible to T1, error otherwise' - 'As' extension returning Result - 'Ok if item is valid regarding custom validations, error otherwise' -
Validate
- 'Async support' -
Map
Bind
andAggregate
overloads with async lambdas and extensions defined on Task<...> - ...
If you miss functionality it can be added easily by writing your own extension methods. If it is useful for us all don't hesitate to make pull request. Finally a little example demonstrating some of the functionality mentioned above (validation, aggregation, async pipeline). Lets cook:
public static async Task FruitSalad()
{
var stock = ImmutableList.Create(
new Fruit("Orange", 155),
new Fruit("Orange", 12),
new Fruit("Apple", 132),
new Fruit("Stink fruit", 1));
var ingredients = ImmutableList.Create("Apple", "Banana", "Pear", "Stink fruit");
const int cookSkillLevel = 3;
static IEnumerable<string> CheckFruit(Fruit fruit)
{
if (fruit.AgeInDays > 20)
yield return $"{fruit.Name} is not fresh";
if (fruit.Name == "Stink fruit")
yield return "Stink fruit, I do not serve that";
}
var salad =
await ingredients
.Select(ingredient =>
stock
.Where(fruit => fruit.Name == ingredient)
.FirstOk(CheckFruit, onEmpty: () => $"No {ingredient} in stock")
)
.Bind(fruits => CutIntoPieces(fruits, cookSkillLevel))
.Map(Serve);
Console.WriteLine(salad.Match(ok => "Salad served successfully!", error => $"No salad today:{Environment.NewLine}{error}"));
}
static Result<Salad> CutIntoPieces(IEnumerable<Fruit> fruits, int skillLevel = 5)
{
try
{
return CutFruits(fruits, skillLevel);
}
catch (Exception e)
{
return Result.Error<Salad>($"Ouch: {e.Message}");
}
}
static Salad CutFruits(IEnumerable<Fruit> fruits, int skillLevel) => skillLevel > 5 ? new Salad(fruits) : throw new Exception("Cut my fingers");
static Task<Salad> Serve(Salad salad) => Task.FromResult(new Salad(salad.Fruits, true));
class Salad
{
public IReadOnlyCollection<Fruit> Fruits { get; }
public bool Served { get; }
public Salad(IEnumerable<Fruit> fruits, bool served = false)
{
Fruits = fruits.ToList();
Served = served;
}
}
class Fruit
{
public string Name { get; }
public int AgeInDays { get; }
public Fruit(string name, int ageInDays)
{
Name = name;
AgeInDays = ageInDays;
}
}
No salad today:
Apple is not fresh
No Banana in stock
No Pear in stock
Stink fruit, I do not serve that
As you can see, all errors are collected as far as possible. Feel free to play around with the cooks skill level, fruits in stock and the ingredients list to finally get your fruit salad.
DISCLAIMER: Right now source generator support in Visual Studio is quite a new feature. Often, especially after adding or updating the generator package intellisense will show errors, even though the code actually compiles. In this cases Visual Studio needs a restart right now (Visual Studio 2022 17.0.5).
After adding the FunicularSwitch.Generators package you can mark a class as result type using the ResultType
attribute. The class has to be abstract and partial with a single generic argument. Ok and Error cases, Map, Bind, Match and some other methods will be generated so you can use your Result just like the one from the FunicularSwitch package. We recommend using a UnionType as error type but you are free to use any type you want to represent failures.
[FunicularSwitch.Generators.ResultType(ErrorType = typeof(MyCustomError))]
public abstract partial class Result<T> {}
To turn all exceptions that might happen during your map, bind, validate, etc. calls into error results, write a static conversion method and mark it with the ExceptionToError
attribute:
public static class MyCustomErrorExtension
{
[FunicularSwitch.Generators.ExceptionToError]
public static MyCustomError ToGenericError(Exception ex) => ...
}
Having the ExceptionToError method, a call like Ok(42).Map(i => 42 / 0)
will return an error result with an error produced by your custom method instead of throwing a DivisionByZero exception.
Using the ExceptionToError
attribute is actually a decision that points into a direction that is different from the way Result is implemented in F#, were Result and the correspondind Error type are meant to model expected domain errors (see fsharpforfunandprofit blog post). You will still have to handle exceptions on the highest parts of your system and there is no 'fail fast' because early exceptions always travel through your hole Result chain.
If your errors can be combined, write an attributed extension method or a member method on your error type that combines two errors into one
public static class MyCustomErrorExtension
{
[FunicularSwitch.Generators.MergeError]
public static MyCustomError Merge(this MyCustomError error, MyCustomError other) => ...
}
and a bunch of methods like Aggregate
, Validate
, AllOk
, FirstOk
and more will appear that make use of the fact that errors can be merged.
There is another useful generator coming with the package. Adding the UnionType
attribute to a base record / class or interface makes Match
extension methods appear for this type. They are also inspired by F# where a match expression has to cover all cases and the compiler helps you with that. Assuming you implemented an error type as a base type and one derived type for every kind of error:
[FunicularSwitch.Generators.UnionType]
public abstract class Error{...}
public sealed class NotFound : Error {...}
public sealed class Failure : Error {...}
public sealed class InvalidInput : Error {...}
the generator detecting the [UnionType]
adds Match methods so you can write:
static string PrintError(Error error) =>
error.Match(
notFound => $"Not found: {notFound.Message}",
failure => $"Ups, something went wrong: {failure.Message} - {failure.Exception}",
invalidInput => $"Name was invalid: {invalidInput.Message}"
);
If you decide to add a case to your Error union all consuming switches break and you never miss a case at runtime!
Match methods are also provided for async case handlers and as extensions on Task<Error>
.
There are also Switch
extension methods generated which are the 'void' versions of Match
, although this is not recommended from a functional point of view :).
static void PrintIfNotFound(Error error) =>
error.Switch(
notFound => Console.WriteLine($"Not found: {notFound.Message}"),
failure => { /*ignore*/ },
invalidInput => { /*ignore*/ }
);
To avoid bad surprises a well defined order of parameters of Match methods is crucial. By default parameters are generated in alphabetical order. This behaviour can be adapted using the CaseOrder
argument on UnionType
attribute (FunicularSwitch.Generators namespace omitted):
//default
[UnionType(CaseOrder = CaseOrder.Alphabetical)]
public abstract class Error{...}
//useful for union types the define their cases as nested subclasses in a well defined order
[UnionType(CaseOrder = CaseOrder.AsDeclared)]
public abstract class Error{...}
//order defined explicitly. Case sort index with [UnionCase] attribute on derived types is expected (generator warning if missing or ambigous)
[UnionType(CaseOrder = CaseOrder.Explicit)]
public abstract class Error{...}
[UnionCase(index: 0)]
public sealed class NotFound : Error {...}
[UnionCase(index: 20)]
public sealed class Failure : Error {...}
[UnionCase(index: 10)]
public sealed class InvalidInput : Error {...}
If your base type is a partial record or class, static factory methods for your derived cases are added:
[UnionType]
public abstract partial record Error;
public record NotFound(int Id, string? Message = "Not found") : Error;
public record InvalidInput(string Message) : Error;
class ExampleConsumer
{
public static void UseGeneratedFactoryMethods()
{
var notFound = Error.NotFound(42); //default value is pulled up to factory methods.
var invalid = Error.InvalidInput("I don't like it");
}
}
Those factory methods are not generated if they would conflict with an existing field, property or method on the base type.
So you can always decide to implement them by yourself. Generation of factory methods on a partial base type can be suppressed
by setting StaticFactoryMethods argument to false: [UnionType(StaticFactoryMethods=false)]
. Currently default values in
constructor parameters from namespaces other than System need full qualification.
If you like to declare your cases as nested types of your base types you can use an underscore prefix or postfix with your nested type name to avoid conflicts with factory methods. Static factory method will then be generated without the underscore. This also works if you use the base type name as prefix or postfix.
[UnionType]
public abstract partial record Failure
{
public record NotFound_(int Id) : Failure;
}
public record InvalidInputFailure(string Message) : Failure;
class ExampleConsumer
{
public static void UseGeneratedFactoryMethods()
{
var notFound = Failure.NotFound(42); //static factory method generated without underscore used in typename NotFound_
var invalid = Failure.InvalidInput("I don't like it"); //static factory method generated without base typename postfix
}
}
If you like union types but don't like excessive typing in C# try the Switchyard Visual Studio extension, which generates the boilerplate code for you. It plays nicely with the FunicularSwitch.Generators package.
The ExtendedEnum
attribute works like UnionType
but for enums:
[FunicularSwitch.Generators.ExtendedEnum]
public enum PlatformIdentifier
{
LinuxDevice,
DeveloperMachine,
WindowsDevice
}
the generator detecting the [ExtendedEnum]
adds Match methods so you can write:
var isGraphicalLinux = PlatformIdentifier.LinuxDevice
.Match(
developerMachine: () => false,
linuxDevice: () => true,
windowsDevice: () => true
);
The default case order for ExtendedEnum
is AsDeclared. To avoid problems with changing case orders, one should always use named parameters in Match and Switch calls!
To generate Match extensions for all types in an assembly use the ExtendEnums
attribute. Flags enums an enums with duplicate values are omitted:
//generate internal Match extension methods for all enums in System (Containing assembly of System.DateTime).
[assembly: ExtendEnums(typeof(System.DateTime), Accessibility = ExtensionAccessibility.Internal)]
//shortcut to generate Match extension methods for all enums in current assembly
[assembly: ExtendEnums]
To generate Match extensions for a specific type in an assembly write:
[assembly: ExtendEnum(typeof(DateTimeKind), CaseOrder = EnumCaseOrder.Alphabetic)]
We're looking forward to pull requests.
We use SemVer for versioning.
bluehands.de