Skip to content

Commit

Permalink
Add fluent error checking and fallible chaining. (#6)
Browse files Browse the repository at this point in the history
* Add fluent error checking and fallible chaining.

* Update README and remove errorMessage prepending for Then chains.
  • Loading branch information
tvandinther authored Mar 21, 2022
1 parent 2d6d89f commit 1ee1cb6
Show file tree
Hide file tree
Showing 7 changed files with 403 additions and 12 deletions.
161 changes: 161 additions & 0 deletions Fallible.Tests/FallibleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,165 @@ public void FromCall_ShouldHaveErrorMessage_ContainingExpression()
}

#endregion

#region Fluent Errors Tests

[Fact]
public void Try_ReturnsValue_WhenOperationSucceeds()
{
const int expectedValue = 42;

var (value, _) = Fallible.Try(() => FallibleOperation(expectedValue, false));

Assert.Equal(expectedValue, value);
}

[Fact]
public void Try_ReturnsError_WhenOperationFails()
{
var (_, error) = Fallible.Try(() => FallibleOperation(42, true));

Assert.NotNull(error);
}

[Fact]
public void Try_PrependsErrorMessage_WhenOperationFails()
{
const string expectedStartString = "Test Error: ";

var (_, error) = Fallible.Try(() => FallibleOperation(42, true), expectedStartString);

Assert.StartsWith(expectedStartString, error.Message);
}

[Fact]
public void Then_ReturnsValue_WhenOperationSucceeds()
{
const int expectedValue = 42;

var result = FallibleOperation(expectedValue, false)
.Then(value => value + 3);

Assert.Equal(45, result);
}

[Fact]
public void Then_ReturnsError_WhenOperationFails()
{
var result = FallibleOperation(42, true)
.Then(value => value + 3);

Assert.NotNull(result.Error);
}

[Fact]
public void OnFail_ReturnsPassesThroughFallible_WhenOperationSucceeds_ErrorReturningOnFail()
{
const int expectedValue = 42;

var result = Fallible.Try(() => FallibleOperation(expectedValue, false))
.OnFail(error => error);

Assert.Equal(expectedValue, result.Value);
}

[Fact]
public void OnFail_ReturnsPassesThroughFallible_WhenOperationSucceeds_TransparentOnFail()
{
const int expectedValue = 42;

var result = Fallible.Try(() => FallibleOperation(expectedValue, false))
.OnFail(_ => {});

Assert.Equal(expectedValue, result.Value);
}

[Fact]
public void OnFail_ReturnsModifiedError_WhenOperationFails_ErrorReturningOnFail()
{
const string expectedStartString = "Test Error: ";

var result = Fallible.Try(() => FallibleOperation(42, true))
.OnFail(error => expectedStartString + error);

Assert.StartsWith(expectedStartString, result.Error.Message);
}

[Fact]
public void OnFail_ReturnsPassesThroughFallible_WhenOperationFails_TransparentOnFail()
{
var result = Fallible.Try(() => FallibleOperation(42, true))
.OnFail(_ => { });

Assert.NotNull(result.Error);
}

[Fact]
public void OnFail_CallsOnFailFunc_WhenOperationFails_TransparentOnFail()
{
var callCount = 0;

Fallible.Try(() => FallibleOperation(42, true))
.OnFail(_ => callCount++);

Assert.Equal(1, callCount);
}

[Fact]
public void OnFail_ReturnsError_WhenOperationFails_ErrorReturningOnFail()
{
var (_, error) = Fallible.Try(() => FallibleOperation(42, true)).OnFail(e => e);

Assert.NotNull(error);
}

[Fact]
public void OnFail_ReturnsError_WhenOperationFails_TransparentOnFail()
{
var (_, error) = Fallible.Try(() => FallibleOperation(42, true)).OnFail(_ => { });

Assert.NotNull(error);
}

[Fact]
public void CanChainFluently_Succeeds()
{
var result = FallibleOperation(42, false)
.Then(value => value + 3)
.OnFail(error => error);

Assert.Equal(45, result.Value);
}

[Fact]
public void CanChainFluently_Fails()
{
var callCount = 0;

var result = FallibleOperation(42, false)
.Then(value => FallibleOperation(value + 3, true))
.OnFail(_ => callCount++);

Assert.Equal(1, callCount);
}

[Fact]
public void CanChainFluently_Fails_DoesNotExecuteThen()
{
var thenCallCount = 0;

FallibleOperation(42, false)
.Then(value => FallibleOperation(value + 3, true))
.Then(_ => thenCallCount++);

Assert.Equal(0, thenCallCount);
}

private Fallible<T> FallibleOperation<T>(T expectedValue, bool fail)
{
if (fail) return new Error("Operation Failed");
return expectedValue;
}

#endregion
}
48 changes: 46 additions & 2 deletions Fallible/Error.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,30 @@

namespace FallibleTypes;


/// <summary>
/// Represents a failed state.
/// </summary>
public class Error : IEquatable<Error>
{
/// <summary>
/// A user-friendly error message.
/// </summary>
public string Message { get; private set; }

/// <summary>
/// The stack trace of the error.
/// </summary>
public readonly string StackTrace;

private readonly string _callingFilePath;
private readonly string _callingMemberName;
private readonly int _callingLineNumber;

/// <summary>
/// Initializes a new instance of the <see cref="Error"/> class.
/// </summary>
/// <param name="message">A user-friendly error message.</param>
public Error(string message, [CallerFilePath] string callingFilePath = "",
[CallerMemberName] string callingMemberName = "", [CallerLineNumber] int callingSourceLineNumber = 0)
{
Expand All @@ -22,23 +38,50 @@ public Error(string message, [CallerFilePath] string callingFilePath = "",
}

public static implicit operator bool(Error? error) => error is not default(Error);

/// <summary>
/// Prepends a message to the error message.
/// </summary>
/// <param name="message">The message to prepend.</param>
/// <param name="error">The error to which the message is being prepended.</param>
/// <returns>The same error with a concatenated error message.</returns>
public static Error operator +(string message, Error error)
{
error.Message = string.Concat(message, error.Message);
return error;
}

/// <summary>
/// Appends a message to the error message.
/// </summary>
/// <param name="message">The message to append.</param>
/// <param name="error">The error to which the message is being appended.</param>
/// <returns>The same error with a concatenated error message.</returns>
public static Error operator +(Error error, string message)
{
error.Message = string.Concat(error.Message, message);
return error;
}

/// <summary>
/// Formats the error message using the specified format string.
/// </summary>
/// <param name="format">A composite format string.</param>
/// <param name="args">An object array that contains zero or more objects to format.</param>
/// <remarks>Uses <see cref="string"/>.Format in the implementation.</remarks>
public void Format(string format, params object[] args)
{
Message = string.Format(format, args);
}

/// <summary>
/// Checks error equality.
/// </summary>
/// <param name="other">The object being compared.</param>
/// <returns>A boolean.</returns>
/// <remarks>Equality is checked by the <see cref="Message"/>, <see cref="_callingFilePath"/>,
/// <see cref="_callingMemberName"/> and <see cref="_callingLineNumber"/> properties. In combination, these properties
/// intend to represent a specific error condition within the application.</remarks>
public bool Equals(Error? other)
{
if (ReferenceEquals(null, other)) return false;
Expand All @@ -52,19 +95,20 @@ public bool Equals(Error? other)

}

/// <inheritdoc cref="Equals(FallibleTypes.Error?)"/>
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((Error) obj);
}

public override int GetHashCode()
{
return HashCode.Combine(Message, _callingFilePath, _callingMemberName, _callingLineNumber);
}

public override string ToString()
{
var stringBuilder = new StringBuilder();
Expand Down
2 changes: 1 addition & 1 deletion Fallible/Fallible.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>0.2.0</PackageVersion>
<PackageVersion>0.2.1</PackageVersion>
<Title>Fallible</Title>
<Authors>Tom van Dinther</Authors>
<Description>An idiomatic way to explicitly define, propagate and handle error states in C#. This library is inspired by Go's errors.</Description>
Expand Down
37 changes: 37 additions & 0 deletions Fallible/FallibleGenericStruct.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,23 @@

namespace FallibleTypes;

/// <summary>
/// A record struct that represents a return type with a possible failure state.
/// </summary>
/// <typeparam name="T">The return type.</typeparam>
/// <remarks><see cref="Fallible{T}"/> will only ever be in a succeeded state or a failed state.</remarks>
public readonly record struct Fallible<T> : IStructuralEquatable, ITuple
{
/// <summary>
/// The value.
/// </summary>
/// <remarks>Will have a default value if in a failed state.</remarks>
public T Value { get; }

/// <summary>
/// A reference to the Error.
/// </summary>
/// <remarks>Will be null if in a succeeded state.</remarks>
public Error Error { get; }

private Fallible(T value, Error error)
Expand All @@ -15,10 +29,33 @@ private Fallible(T value, Error error)
Error = error;
}

/// <summary>
/// Creates <see cref="Fallible{T}"/> in a failed state."/>
/// </summary>
/// <param name="error">The error to be contained.</param>
/// <returns>A fallible object.</returns>
public static implicit operator Fallible<T>(Error error) => new(default!, error);

/// <summary>
/// Creates <see cref="Fallible{T}"/> in a succeeded state.
/// </summary>
/// <param name="value">The value to be contained.</param>
/// <returns>A fallible object.</returns>
public static implicit operator Fallible<T>(T value) => new(value, default!);

/// <summary>
/// Unwraps the value if in a succeeded state or just the error if in a failed state.
/// </summary>
/// <param name="outer">The outer <see cref="Fallible{T}"/>.</param>
/// <returns>An unwrapped <see cref="Fallible{T}"/> object.</returns>
public static implicit operator Fallible<T>(Fallible<Fallible<T>> outer) => outer.Error ? outer.Error : outer.Value;

/// <summary>
/// Deconstructs <see cref="Fallible{T}"/> into a value and error.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="error">The error.</param>
/// <remarks>Only one of value or error will be populated. Perform a boolean check on error before using the value.</remarks>
public void Deconstruct(out T value, out Error error)
{
value = Value;
Expand Down
Loading

0 comments on commit 1ee1cb6

Please sign in to comment.