diff --git a/Railway/Error.cs b/Railway/Error.cs index 7c59dc9..713359c 100644 --- a/Railway/Error.cs +++ b/Railway/Error.cs @@ -58,7 +58,7 @@ public abstract class Error : IEquatable, IComparable [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] public static Error Many(Error error1, Error error2) => (error1, error2) switch { - (null, null) => new ManyErrors(ImmutableArray.Empty), + (null, null) => EmptyError.Instance, (Error err, null) => err, (Error err, { IsEmpty: true }) => err, (null, Error err) => err, @@ -70,8 +70,10 @@ public abstract class Error : IEquatable, IComparable /// /// Collects many errors into a single type, called [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Error Many(params Error[] errors) => errors.Length switch + public static Error Many(params Error[] errors) => errors?.Length switch { + null => EmptyError.Instance, + 0 => EmptyError.Instance, 1 => errors[0], _ => new ManyErrors(errors) }; @@ -80,7 +82,11 @@ public abstract class Error : IEquatable, IComparable /// /// Collects many errors into a single type, called [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Error Many(IEnumerable errors) => new ManyErrors(errors); + public static Error Many(IEnumerable errors) + { + var result = new ManyErrors(errors); + return result.IsEmpty ? EmptyError.Instance : result; + } [Pure] public abstract string Type { get; } [Pure] public abstract string Message { get; } @@ -93,6 +99,12 @@ public abstract class Error : IEquatable, IComparable [Pure] public abstract bool IsExpected { get; } [Pure] public abstract bool IsExceptional { get; } + /// + /// A reusable singleton representing the absence of an error (null-object pattern). + /// + [Pure] + public static Error Empty => EmptyError.Instance; + [Pure] public Error Append(Error? next) { if (next is null || next.IsEmpty) @@ -153,6 +165,42 @@ public abstract class Error : IEquatable, IComparable [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] internal virtual Error AccessUnsafe(int position) => this; } +public sealed class EmptyError : Error +{ + public static readonly EmptyError Instance = new(); + + private EmptyError() { } + + [Pure] public override string Type => "empty"; + [Pure] public override string Message => ""; + [Pure] public override int Count => 0; + [Pure] public override bool IsEmpty => true; + [Pure] public override bool IsExpected => true; + [Pure] public override bool IsExceptional => false; + + [Pure] + public override IEnumerable ToEnumerable() { yield break; } + + [Pure] + public override Exception ToException() => + new ErrorException(Type, Message); + + [Pure] + public override int CompareTo(Error? other) => + other is EmptyError ? 0 : -1; + + [Pure] + public override bool Equals(Error? other) => + ReferenceEquals(this, other) || (other?.IsEmpty == true); + + [Pure] + public override int GetHashCode() => 0; + + [Pure] + public override bool IsSimilarTo(Error? other) => + other is EmptyError || (other?.IsEmpty == true); +} + [JsonConverter(typeof(ExpectedErrorJsonConverter))] public sealed class ExpectedError : Error { diff --git a/Railway/ErrorJsonConverter.cs b/Railway/ErrorJsonConverter.cs index cd697ad..57d395d 100644 --- a/Railway/ErrorJsonConverter.cs +++ b/Railway/ErrorJsonConverter.cs @@ -18,11 +18,19 @@ public sealed class ErrorJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, Error value, JsonSerializerOptions options) { + if (value is EmptyError) + { + writer.WriteNullValue(); + return; + } + if (value is ManyErrors manyErrors) { writer.WriteStartArray(); foreach (var err in manyErrors) { + if (err.IsEmpty) + continue; WriteOne(writer, err); } writer.WriteEndArray(); diff --git a/Raliway.Tests/Errors/Construction.cs b/Raliway.Tests/Errors/Construction.cs index f31e3e1..30bc4f1 100644 --- a/Raliway.Tests/Errors/Construction.cs +++ b/Raliway.Tests/Errors/Construction.cs @@ -145,14 +145,14 @@ public class Construction public class Many_Factory { [Fact] - public void With_both_null_returns_empty_ManyErrors() + public void With_both_null_returns_EmptyError() { // Given // When var error = Error.Many(null!, null!); // Then - error.ShouldBeOfType(); + error.ShouldBeOfType(); error.IsEmpty.ShouldBeTrue(); error.Count.ShouldBe(0); } @@ -216,14 +216,14 @@ public class Construction } [Fact] - public void Params_with_empty_array_returns_empty_ManyErrors() + public void Params_with_empty_array_returns_EmptyError() { // Given // When var result = Error.Many(Array.Empty()); // Then - result.ShouldBeOfType(); + result.ShouldBeOfType(); result.IsEmpty.ShouldBeTrue(); result.Count.ShouldBe(0); } @@ -365,9 +365,10 @@ public class Construction } [Fact] - public void ManyErrors_IsEmpty_when_empty() + public void EmptyError_IsEmpty() { var error = Error.Many(Array.Empty()); + error.ShouldBeOfType(); error.IsEmpty.ShouldBeTrue(); } @@ -569,4 +570,167 @@ public class Construction message.ShouldBe("custom message"); } } + + public class EmptyError_Tests + { + [Fact] + public void Error_Empty_returns_EmptyError_singleton() + { + Error.Empty.ShouldBeOfType(); + Error.Empty.ShouldBeSameAs(EmptyError.Instance); + } + + [Fact] + public void Properties_are_correct() + { + var error = Error.Empty; + error.Type.ShouldBe("empty"); + error.Message.ShouldBe(""); + error.Count.ShouldBe(0); + error.IsEmpty.ShouldBeTrue(); + error.IsExpected.ShouldBeTrue(); + error.IsExceptional.ShouldBeFalse(); + } + + [Fact] + public void ToEnumerable_returns_empty() + { + Error.Empty.ToEnumerable().ShouldBeEmpty(); + } + + [Fact] + public void ToException_returns_ErrorException() + { + var ex = Error.Empty.ToException(); + ex.ShouldBeOfType(); + var errEx = (ErrorException)ex; + errEx.Type.ShouldBe("empty"); + errEx.Message.ShouldBe(""); + } + + [Fact] + public void Deconstruct_produces_type_and_message() + { + var (type, message) = Error.Empty; + type.ShouldBe("empty"); + message.ShouldBe(""); + } + + [Fact] + public void ExtensionData_is_empty() + { + Error.Empty.ExtensionData.ShouldBeEmpty(); + } + + [Fact] + public void Indexer_returns_null() + { + Error.Empty["anything"].ShouldBeNull(); + } + + [Fact] + public void Equals_self() + { + Error.Empty.Equals(Error.Empty).ShouldBeTrue(); + Error.Empty.Equals(EmptyError.Instance).ShouldBeTrue(); + } + + [Fact] + public void Equals_other_empty_error() + { + // An empty ManyErrors is also IsEmpty=true, so EmptyError equals it + var emptyMany = new ManyErrors(Array.Empty()); + Error.Empty.Equals(emptyMany).ShouldBeTrue(); + } + + [Fact] + public void Equals_non_empty_is_false() + { + var realError = Error.New("test"); + Error.Empty.Equals(realError).ShouldBeFalse(); + } + + [Fact] + public void IsSimilarTo_empty_error() + { + var emptyMany = new ManyErrors(Array.Empty()); + Error.Empty.IsSimilarTo(emptyMany).ShouldBeTrue(); + } + + [Fact] + public void IsSimilarTo_non_empty_is_false() + { + Error.Empty.IsSimilarTo(Error.New("test")).ShouldBeFalse(); + } + + [Fact] + public void CompareTo_sorts_before_non_empty() + { + Error.Empty.CompareTo(Error.New("test")).ShouldBeLessThan(0); + } + + [Fact] + public void CompareTo_self_is_zero() + { + Error.Empty.CompareTo(Error.Empty).ShouldBe(0); + } + + [Fact] + public void ToString_returns_empty_message() + { + Error.Empty.ToString().ShouldBe(""); + } + + [Fact] + public void Operator_equals() + { + (Error.Empty == EmptyError.Instance).ShouldBeTrue(); + (Error.Empty == Error.New("test")).ShouldBeFalse(); + } + + [Fact] + public void Operator_not_equals() + { + (Error.Empty != Error.New("test")).ShouldBeTrue(); + (Error.Empty != EmptyError.Instance).ShouldBeFalse(); + } + + [Fact] + public void Append_EmptyError_returns_other() + { + var real = Error.New("test"); + Error.Empty.Append(real).ShouldBe(real); + } + + [Fact] + public void Append_to_EmptyError_returns_real() + { + var real = Error.New("test"); + real.Append(Error.Empty).ShouldBe(real); + } + + [Fact] + public void Operator_plus_EmptyError() + { + var real = Error.New("test"); + (Error.Empty + real).ShouldBe(real); + (real + Error.Empty).ShouldBe(real); + } + + [Fact] + public void Many_with_EmptyError_absorbs_it() + { + var real = Error.New("test"); + var result = Error.Many(Error.Empty, real); + result.ShouldBeOfType(); + result.ShouldBe(real); + } + + [Fact] + public void GetHashCode_is_stable() + { + Error.Empty.GetHashCode().ShouldBe(0); + EmptyError.Instance.GetHashCode().ShouldBe(0); + } + } } diff --git a/Raliway.Tests/Errors/Serialization_RoundTrip.cs b/Raliway.Tests/Errors/Serialization_RoundTrip.cs index 91bdc72..a19778e 100644 --- a/Raliway.Tests/Errors/Serialization_RoundTrip.cs +++ b/Raliway.Tests/Errors/Serialization_RoundTrip.cs @@ -152,19 +152,31 @@ public class Serialization_RoundTrip } [Fact] - public void Empty_ManyErrors_round_trip() + public void EmptyError_serializes_to_null() { // Given - Error original = Error.Many(Array.Empty()); + var error = Error.Empty; // When - var json = JsonSerializer.Serialize(original); - var deserialized = JsonSerializer.Deserialize(json); + var json = JsonSerializer.Serialize(error); // Then - deserialized.ShouldBeOfType(); - deserialized!.IsEmpty.ShouldBeTrue(); - deserialized.Count.ShouldBe(0); + json.ShouldBe("null"); + } + + [Fact] + public void EmptyError_filtered_from_ManyErrors_serialization() + { + // Given + var real = Error.New("real_error"); + var errors = Error.Many(Error.Empty, real); + + // When + var json = JsonSerializer.Serialize(errors); + + // Then + json.ShouldNotContain("empty"); + json.ShouldContain("real_error"); } [Fact]