Skip to content

Just a simple results pattern experiment I wanted to mess with.

Notifications You must be signed in to change notification settings

tombatron/Tombatron.Results

Repository files navigation

Results!

.NET Build

Overview

What you have here is a basic implementation of the "Results pattern" which by itself isn't that interesting. No, this one has a Roslyn analyzer that was inspired by the Rust language, who's Option type has requirements that if not met will throw compile time errors.

Is that useful in a .NET-based application? I have no idea, but I thought it would be fun to build so here we are.

Installation

Installation is a snap. I recommend using the Tombatron.Results.All meta-package since it bundles everything together.

The following are the related published packages.

Package Name Description
Tombatron.Results This package contains the results pattern implementation.
Tombatron.Results.Analyzers This package contains the Roslyn analyzer for the Tombatron.Results package.
Tombatron.Results.All This is a meta-package that contains both Tombatron.Results and **Tombatron.Results.Analyzers.

Usage

Tombatron.Results

Usage of the simplistic result pattern implementation is... simple!

Let's say that you have a method that returns a string, the method signature might look something like:

public string MyExampleMethod()

Instead of directly returning a string type directly, we'll wrap our result with the generic Result<T> class, so now our method signature will look like:

public Result<string> MyExampleMethod()

Next, we need to create an instance of Result<T> or in this case Result<string>. Result<T> is an abstract class, so we can't directly create an instance of it, so instead we'll leverage one of the two static factory methods that exist on the Result<T> type to create our result.

The first method is Result<T>.Ok(T Value). This method will return a concrete instance of the Ok<T> class (which inherits from the Result<T> class). You simply pass in the value you want to wrap as the sole parameter and you're good to go.

Example:

var okValue = Result<string>.Ok("Hi friend!");

Above I mentioned that Result<T>.Ok(T value) is a "static factory method". If you'd rather construct the Ok<T> class directly, that's fine too. The lone constructor parameter is the value that you're wrapping.

Example:

var okValue = new Ok<string>("Sup bruh.");

The next method is Result<T>.Error(string message). You'll use this method to communicate issues that occurred within a method. This static factory method will return a concrete instance of Error<T>.

Example:

var errorValue = Result<string>.Error("Oh noes!");

For scenarios where you need to report multiple errors at once, you can use Result<T>.Error(string[] messages):

Example:

var multipleErrors = Result<string>.Error(new[] { 
    "Validation failed for field 'Name'", 
    "Validation failed for field 'Email'", 
    "Validation failed for field 'Phone'" 
});

(New in Version 3.0) In the event that you're handling an Error result and you need to instantiate a new Error without losing reference to the original, you can now nest an Error within an Error instance:

Example:

var childError = Result<string>.Error("Child error.");

var errorWithChild = Result<string>.Error("This would be the parent error.", childError);

Again, like the Ok<T> class, we can directly construct the Error<T> class instead of using the static factory method.

Example:

var errorValue = new Error<string>("What did you do?!");

// Or with multiple messages:
var multipleErrors = new Error<string>(new[] {
    "Database connection failed",
    "Retry attempts exhausted"
});

That's really all there is to creating a result instance, but what about handling a result instance?

Well, the first thing that I'll say is that you should try to always implement handling in your code for both possiblities of Ok<T> or Error<T>. In fact, if you install the attendant Roslyn analyzer (Tombatron.Results.Analyzers) it'll raise a compiler error if you don't!

The following are some different ways that you could deal with a result value.

First, let's take a look at using a pattern matching if-statement.

public void ExampleMethod()
{
    var resultObject = GetResultObject(); // I'm terrible at creating examples.

    if (resultObject is Ok<SomeNonExistantThing> ok) // Names are even more difficult. 
    {
        // Do some stuff
    }

    if (resultObject is Error<SomeNonExistantThing> error) // Seriously.
    {
        // Do something completely different. 
    }
}

Since a Result<T> can only be an instance of Ok<T> or Error<T> you've covered all of the possibilities, so in this case the Roslyn analyzer wouldn't complain.

Now let's take a look at using pattern matching with a switch statment.

public string ExampleMethod()
{
    var resultObject = GetResultObject(); // I'm still terrible.

    return resultObject switch 
    {
        Ok<string> => "OK",
        Error<string> err => err.
    };
}

Simple enough. Notice that I'm not handling the default case. You won't get a compiler warning about not handling the default case, because we know that there are only two possibilities (Ok and Error) so in this case we're using the analyzer to suppress the warning.

BUT! What if you don't want to go through the motions of handling each possible outcome? Well, I ripped a page out of the Rust handbook and provided the methods Unwrap() and UnwrapOr(T defaultValue). Since the non-generic version of the Result class doesn't carry any data with it, the convenience method for flattening a result is called VerifyOk(). The result of this is essentially a no-op if there is no error but an exception if there is.

The Unwrap() method will assume you're after the Ok<T> value of the result, but if you call Unwrap() on an value that isn't Ok<T> the library will panic... er... it'll throw a ResultUnwrapException.

Alternatively, you can call the UnwrapOr method which will receive an argument of the value you'd like to return in the event that the instance of Result<T> is an Error<T>. The only catch here is that the default value has to be the same type as that wrapped value of the result.

ErrorDetails (New in Version 3.2)

In version 3.2 I introduced a new interface called IErrorDetails. This interface is used as a way to capture the details of an error type while giving the developer an opportunity to create custom error types.

What is this good for? Pattern matching on Error types (at least that is why I introduced it...)! Because nobody likes stringly-typed applications.

Let's say that you want to be able to handle multiple error cases from a single method. For each error case you'd implement a custom IErrorDetails implementation.

For example, let's imagine that I want to be able to handle cases where a socket times out, versus other kinds of errors.

The first thing that you need to do is create a custom IErrorDetails implementation:

public class SocketTimeoutError : IErrorDetails
{
    public IErrorResult? ChildError { get; }
    public string[] Messages { get; }
    public string CallerFilePath { get; }
    public int CallerLineNumber { get; }

    public SocketTimeoutError(IErrorResult? childError, string[] messages, string callerFilePath, int callerLineNumber)
    {
        ChildError = childError;
        Messages = messages;
        CallerFilePath = callerFilePath;
        CallerLineNumber = callerLineNumber;
    }

    public static Result<T> Create<T>(
        string message,
        IErrorResult? childError = null,
        [CallerFilePath] string callerFilePath = "",
        [CallerLineNumber] int callerLineNumber = 0) where T : notnull =>
        Create<T>([message], childError, callerFilePath, callerLineNumber);

    public static Result<T> Create<T>(
        string[] messages,
        IErrorResult? childError = null,
        [CallerFilePath] string callerFilePath = "",
        [CallerLineNumber] int callerLineNumber = 0) where T : notnull =>
        Result<T>.Error(new SocketTimeoutError(childError, messages, callerFilePath, callerLineNumber));
}

Now I know what you're saying, "Yo, that's a lot of boilerplate code..."

I totally agree! So I went and created a Roslyn refactoring that will do a base implementation of the interface including the two static factory methods that I think make this a little more convenient to use.

In the above example I've gone a little further than strictly necessary by creating a static factory method that will return an instance of the Result type.

But now when I want to return a SocketTimeoutError as my Error result type I can simply return :

return SocketTimeoutError.Create<string>("This is a sample socket timeout error.");

...with that I can now use pattern matching for handling multiple error cases instead of having to rely on string analysis.

if (result is Error<string> { Details: SocketTimeoutError }) 
{
    // Do stuff here.
}

Tombatron.Results.Analyzer

My ulterior motive for creating this library was to give me an excuse to learn a little bit about developing a Roslyn analyzer. The canned examples and simple use cases didn't really interest me. You know what did interest me? How Rust will refuse to compile if you don't handle both branches (I know there are caveats to this, settle down) of an instance of Option<T>.

The following is an example of the kind of error that you can expect if you don't handle both cases of a Result<T> instance.

Building

Building this project should be no more difficult than cloning this repository and issuing the dotnet build command.

Something to be aware of is that the Tombatron.Results.Analyzers.Samples project is excluded from building with the rest of the solution. This is because that project contains demonstrations of the Roslyn analyzer causing a compiler error. When building that project seperately, you'll see a demonstration of what happens when

About

Just a simple results pattern experiment I wanted to mess with.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •  

Languages