diff --git a/Fallible.Tests/FallibleTests.cs b/Fallible.Tests/FallibleTests.cs index 6608e72..6598645 100644 --- a/Fallible.Tests/FallibleTests.cs +++ b/Fallible.Tests/FallibleTests.cs @@ -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 FallibleOperation(T expectedValue, bool fail) + { + if (fail) return new Error("Operation Failed"); + return expectedValue; + } + + #endregion } \ No newline at end of file diff --git a/Fallible/Error.cs b/Fallible/Error.cs index 6877ba6..c600a9a 100644 --- a/Fallible/Error.cs +++ b/Fallible/Error.cs @@ -3,14 +3,30 @@ namespace FallibleTypes; + +/// +/// Represents a failed state. +/// public class Error : IEquatable { + /// + /// A user-friendly error message. + /// public string Message { get; private set; } + + /// + /// The stack trace of the error. + /// public readonly string StackTrace; + private readonly string _callingFilePath; private readonly string _callingMemberName; private readonly int _callingLineNumber; + /// + /// Initializes a new instance of the class. + /// + /// A user-friendly error message. public Error(string message, [CallerFilePath] string callingFilePath = "", [CallerMemberName] string callingMemberName = "", [CallerLineNumber] int callingSourceLineNumber = 0) { @@ -22,23 +38,50 @@ public Error(string message, [CallerFilePath] string callingFilePath = "", } public static implicit operator bool(Error? error) => error is not default(Error); + + /// + /// Prepends a message to the error message. + /// + /// The message to prepend. + /// The error to which the message is being prepended. + /// The same error with a concatenated error message. public static Error operator +(string message, Error error) { error.Message = string.Concat(message, error.Message); return error; } + /// + /// Appends a message to the error message. + /// + /// The message to append. + /// The error to which the message is being appended. + /// The same error with a concatenated error message. public static Error operator +(Error error, string message) { error.Message = string.Concat(error.Message, message); return error; } + /// + /// Formats the error message using the specified format string. + /// + /// A composite format string. + /// An object array that contains zero or more objects to format. + /// Uses .Format in the implementation. public void Format(string format, params object[] args) { Message = string.Format(format, args); } + /// + /// Checks error equality. + /// + /// The object being compared. + /// A boolean. + /// Equality is checked by the , , + /// and properties. In combination, these properties + /// intend to represent a specific error condition within the application. public bool Equals(Error? other) { if (ReferenceEquals(null, other)) return false; @@ -52,6 +95,7 @@ public bool Equals(Error? other) } + /// public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; @@ -59,12 +103,12 @@ public override bool Equals(object? obj) 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(); diff --git a/Fallible/Fallible.csproj b/Fallible/Fallible.csproj index 2357fd8..7edce6d 100644 --- a/Fallible/Fallible.csproj +++ b/Fallible/Fallible.csproj @@ -5,7 +5,7 @@ enable enable true - 0.2.0 + 0.2.1 Fallible Tom van Dinther An idiomatic way to explicitly define, propagate and handle error states in C#. This library is inspired by Go's errors. diff --git a/Fallible/FallibleGenericStruct.cs b/Fallible/FallibleGenericStruct.cs index c86667b..ce72382 100644 --- a/Fallible/FallibleGenericStruct.cs +++ b/Fallible/FallibleGenericStruct.cs @@ -4,9 +4,23 @@ namespace FallibleTypes; +/// +/// A record struct that represents a return type with a possible failure state. +/// +/// The return type. +/// will only ever be in a succeeded state or a failed state. public readonly record struct Fallible : IStructuralEquatable, ITuple { + /// + /// The value. + /// + /// Will have a default value if in a failed state. public T Value { get; } + + /// + /// A reference to the Error. + /// + /// Will be null if in a succeeded state. public Error Error { get; } private Fallible(T value, Error error) @@ -15,10 +29,33 @@ private Fallible(T value, Error error) Error = error; } + /// + /// Creates in a failed state."/> + /// + /// The error to be contained. + /// A fallible object. public static implicit operator Fallible(Error error) => new(default!, error); + + /// + /// Creates in a succeeded state. + /// + /// The value to be contained. + /// A fallible object. public static implicit operator Fallible(T value) => new(value, default!); + + /// + /// Unwraps the value if in a succeeded state or just the error if in a failed state. + /// + /// The outer . + /// An unwrapped object. public static implicit operator Fallible(Fallible> outer) => outer.Error ? outer.Error : outer.Value; + /// + /// Deconstructs into a value and error. + /// + /// The value. + /// The error. + /// Only one of value or error will be populated. Perform a boolean check on error before using the value. public void Deconstruct(out T value, out Error error) { value = Value; diff --git a/Fallible/FallibleStatic.cs b/Fallible/FallibleStatic.cs index e6af54d..42e0b73 100644 --- a/Fallible/FallibleStatic.cs +++ b/Fallible/FallibleStatic.cs @@ -2,13 +2,28 @@ namespace FallibleTypes; +/// +/// Utility class for working with Fallible types. +/// public static class Fallible { - public static Fallible Try(Func action, [CallerArgumentExpression("action")] string expression = "") + /// + /// Returns a fallible instance. + /// + /// This is the only way to instantiate . + public static Fallible Return => new Void(); + + /// + /// Will execute an operation and try to catch any exceptions and returning an error if caught. + /// + /// The operation to execute. + /// The type of the fallible being returned. + /// A fallible result. + public static Fallible Try(Func @try, [CallerArgumentExpression("try")] string expression = "") { try { - var value = action(); + var value = @try(); return value; } catch (Exception e) @@ -17,5 +32,87 @@ public static Fallible Try(Func action, [CallerArgume } } - public static Fallible Return => new Void(); + /// + /// Will execute a fallible operation and return the result. If the operation fails, the errorMessage will be + /// prepended. + /// + /// The expression to execute. + /// An optional message to prepend to the error on failure. + /// The type of the fallible being returned. + /// A fallible result. + /// Allows for simple conditional wrapping of error messages. + public static Fallible Try(Func> @try, string errorMessage = "") + { + var (value, error) = @try(); + if (error) return errorMessage + error; + + return value; + } + + /// + /// Allows for chaining of non-fallible operations. + /// + /// The fallible being chained. + /// The expression to be chained if there is no error. + /// An optional message to prepend to the error on failure. + /// The type of the fallible being chained. + /// The type of the fallible being returned. + /// A fallible result. + /// If any of the operations fail, the chain stays in an error state. + public static Fallible Then(this Fallible fallible, Func then) + { + var (value, error) = fallible; + if (error) return error; + + return then(value); + } + + /// + /// Allows for chaining of fallible operations. + /// + /// The fallible being chained. + /// The expression to be chained if there is no error. + /// An optional message to prepend to the error on failure. + /// The type of the fallible being chained. + /// The type of the fallible being returned. + /// A fallible result. + /// If any of the operations fail, the chain stays in an error state. + public static Fallible Then(this Fallible fallible, Func> then) + { + var (value, error) = fallible; + if (error) return error; + + return then(value); + } + + /// + /// Allows for chaining of expressions that are executed if the chain is in a failure state. Chains of OnFail will + /// continue to execute until exhausted. + /// + /// The fallible being chained. + /// The expression to be chained if there is an error. + /// The type of the fallible being chained. + /// The original fallible result. + /// This will propagate a modified error object. Useful for modifying error messages. + public static Fallible OnFail(this Fallible fallible, Func onFail) + { + var (_, error) = fallible; + return error ? onFail(error) : fallible; + } + + /// + /// Allows for chaining of expressions that are executed if the chain is in a failure state. Chains of OnFail will + /// continue to execute until exhausted. + /// + /// The fallible being chained. + /// The expression to be chained if there is an error. + /// The type of the fallible being chained. + /// The original fallible result. + public static Fallible OnFail(this Fallible fallible, Action onFail) + { + var (_, error) = fallible; + if (error) onFail(error); + + return fallible; + } } \ No newline at end of file diff --git a/Fallible/Void.cs b/Fallible/Void.cs index 58df930..cfd971d 100644 --- a/Fallible/Void.cs +++ b/Fallible/Void.cs @@ -1,5 +1,8 @@ namespace FallibleTypes; +/// +/// A special type to represent no value with the generic. +/// public class Void { internal Void() { } diff --git a/README.md b/README.md index d022a77..2f17d8d 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ For example, `DateTime.Parse` can throw two exceptions: `FormatException` and `A var (result, error) = Fallible.Try(() => DateTime.Parse("1/1/2019")); ``` -### Chaining error messages +### Concatenating error messages When dealing with an `Error` object, often you may want to pass the error up the call stack. As it is passed up the call stack, the level of abstraction is increased which can give increasing context to the error message. To facilitate this best practice, the `Error` object allows string concatenation with itself. @@ -170,20 +170,69 @@ To ensure that error messages are not accidentally overwritten, directly setting return error.Format("Could not find user: {0}: Aborting...", error.Message); ``` +### Fluent Error Handling + +You can use functional chaining to handle errors in a more declarative way. For example, the following code will get the user using the method with the declared signature and pass it into the next method if successful or return an `Error` object if the user is not found: + +```c# +public Fallible GetUserFromDB(UserId id); + +return GetUserFromDB(userId).Then(user => { + // Do something with user +}); +``` + +This code is equivalent to the following: + +```c# +public Fallible GetUserFromDB(UserId id); + +var (user, error) = GetUserFromDB(userId); +if (error) return error; + +// Do something with user +``` + +You can chain multiple `Then` calls but if any of them result in a failed state, then the entire chain will fail. You can add a call to `OnFail` in the middle of the chain to handle errors. + +```c# +public Fallible GetUserFromDB(UserId id); + +return GetUserFromDB(userId).Then(user => { + // Do something with user +}).OnFail(error => { + // Handle error +}); +``` + +Multiple calls to `OnFail` can be chained together with each of them executing in turn if the chain is in a failed state. + +Simply stated, `Then` and `OnFail` can be chained together as many times as necessary. When a `Then` expression is successful, the value of `Fallible` is passed into the next `Then` expression. In this scenario, the chain is in a succeeded state and will not execute any expressions chained by `OnFail`. Once a `Then` expression fails, the chain will be in a failed state and any `OnFail` expressions will then be executed and finally return, skipping over `Then` expressions. + +The object returned from the chain will be `Fallible` where `T` is the type of the value returned by the last `Then` expression. + +#### Try + +You can use the `Try` static method to wrap a call to a method that returns `Fallible`. You can optionally add an error message as a parameter to the `Try` method. This will be prepend to the error message should the tried expression fail. + +```c# +public Fallible GetUserFromDB(UserId id); + +var (user, error) = Fallible.Try(() => GetUserFromDB(userId), "Could not find user: "); + +Console.WriteLine(error); // "Could not find user: Database is not connected" +``` + ## Final Notes If you are using this library in your project, it does not mean that you can not use exceptions. Exceptions are still an effective way of quickly returning up the call stack when the application is in a serious erroneous state. This usage would be similar to `panic()` in Go. I hope you enjoy using this library and find it an enjoyable addition to the C# coding experience. ### Future Work -#### Error Handling - -I am considering adding in utility classes to make it easier to handle error states and reduce the amount of boilerplate that the pattern creates. Although it is already minimal, some usages of the library may show the need for these classes. - #### Error Aggregation While typically an application will exit early upon encountering an error state, it could sometimes be beneficial to continue processing and aggregate all the error states into a single error state. This could be useful for example if you are validating a series of values and you want to collect everything wrong about a particular call before exiting. #### Extensions to the Standard Library -Adding extension methods to the standard library is a potential improvement for the library. For example, `bool Try(out value)` type methods could be extended to support `Fallible Try()` signatures. +I would like to add several proxy classes to encapsulate standard library functions with `Fallible` return types where exceptions would otherwise be thrown. I will start with `System.Collections.Generic` as this is frequently used.