Functions in dotnet can only return one value but there are times when you want the option to be able to return an error result. You could throw an exception and use a try catch to catch the error, but that feels a little overkill for more cases.

Result class

A more elegant solution is to use a Result class that can be either the result of the function or an error result. A simple implementation of such a class is:

public class Result<TError, TValue> where TError : IError
{
    private readonly TError? _error;
    private readonly TValue? _value;

    public Result(TValue value) => _value = value;
    public Result(TError error) => _error = error;

    public bool IsSuccess => _value is not null;

    public TValue? Value => _value ?? default;
    public TError? Error => _error;
}
public interface IError
{
    string Message { get; init; }
}

This class could be used in a function by defining the return value as Result<GenericError,ReturnData> where GenericError is an implementation of IError and ReturnData is whatever class that you would normally return from the function.

Because there is a constructor for both TError and TValue you can create a new Result class by passing either a error or the result into the constructor.

return new Result<GenericError, ReturnData>(new GenericError("Error"));
// or
return new Result<GenericError, ReturnData>(new ReturnData { Message = "Success" });

Implicit constructors

This can be made more simple by adding implicit operators to the Result class to automatically convert return values to the return type.

    public static implicit operator Result<TError, TValue>(TValue value) => new(value);

    public static implicit operator Result<TError, TValue>(TError error) => new(error)

These allow you to remove the Result type from the return statement, so the return statements can look like

return new GenericError("Error");
// or
return new ReturnData { Message = "Success" };

Processing result

But how do you check the result? You can check the success property on the returned value, processing the error if it is false or getting the value if it true.

if (!result.IsSuccess)
{
    Console.WriteLine($"Error is {result.Error!.Message}");
}
else 
{
    // work with result.Value
}

But we can do better than that, if we add a match function to the result class we can make the error check a bit more declarative

public TResult Match<TResult>(Func<TError, TResult> failure, Func<TValue, TResult> success) =>
    IsSuccess
        ? success(Value ?? throw new NullReferenceException())
        : failure(Error ?? throw new NullReferenceException());

And it can be used like

var processResult = notError.Match<string>(
    x =>
    {
        Console.WriteLine($"Error is {x.Message}");
        return "Error";
    },
    x =>
    {
        Console.WriteLine($"Data is {x.Message}");
        return x.Message;
    });

Full class

public class Result<TError, TValue> where TError : IError
{
    private readonly TError? _error;
    private readonly TValue? _value;

    public Result(TValue value) => _value = value;
    public Result(TError error) => _error = error;

    public bool IsSuccess => _value is not null;

    public TValue? Value => _value ?? default;
    public TError? Error => _error;

    public static implicit operator Result<TError, TValue>(TValue value) => new(value);

    public static implicit operator Result<TError, TValue>(TError error) => new(error);

    public TResult Match<TResult>(Func<TError, TResult> failure, Func<TValue, TResult> success) =>
        // Here we can throw exception because it's a failure case, not an error
        // It's normally impossible to get an exception from here, but compiler needs it
        IsSuccess
            ? success(Value ?? throw new NullReferenceException())
            : failure(Error ?? throw new NullReferenceException());
}

Additional tip

It can be annoying to have to put the full type in all the function declarations, but there is a solution by using a global using type alias statement. In a file called GlobalUsings.cs you can define an alias like

global using StandardReturn = Helpers.Result<Helpers.GenericError, Helpers.ReturnData>;

then you can use the type StandardReturn anywhere you had previously used Result<GenericError,ReturnData>