record types with collection properties & collections with value semantics

Our team faced a similar problem and started implementing based on the idea from @jeroenh. However, we ran into the issue of records that could no longer be deserialized from json with System.Text.Json. Here's the gist (posted below as well) with everything we had to create in order to support this deep equality on records.

ImmutableArrayWithDeepEquality

using System.Collections.Generic;
using System.Linq;

namespace System.Collections.Immutable
{
    [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverterForImmutableArrayWithDeepEqualityFactory))]
    public struct ImmutableArrayWithDeepEquality<T> : IEquatable<ImmutableArrayWithDeepEquality<T>>, IEnumerable, IEnumerable<T>
    {
        private readonly ImmutableArray<T> _list;

        public ImmutableArrayWithDeepEquality(ImmutableArray<T> list) => _list = list;

        #region ImmutableArray Implementation

        public T this[int index] => _list[index];

        public int Count => _list.Length;

        public ImmutableArrayWithDeepEquality<T> Add(T value) => _list.Add(value).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> AddRange(IEnumerable<T> items) => _list.AddRange(items).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> Clear() => _list.Clear().WithDeepEquality();
        public ImmutableArray<T>.Enumerator GetEnumerator() => _list.GetEnumerator();
        public int IndexOf(T item, int index, int count, IEqualityComparer<T> equalityComparer) => _list.IndexOf(item, index, count, equalityComparer);
        public ImmutableArrayWithDeepEquality<T> Insert(int index, T element) => _list.Insert(index, element).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> InsertRange(int index, IEnumerable<T> items) => _list.InsertRange(index, items).WithDeepEquality();
        public int LastIndexOf(T item, int index, int count, IEqualityComparer<T> equalityComparer) => _list.LastIndexOf(item, index, count, equalityComparer);
        public ImmutableArrayWithDeepEquality<T> Remove(T value, IEqualityComparer<T> equalityComparer) => _list.Remove(value, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveAll(Predicate<T> match) => _list.RemoveAll(match).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveAt(int index) => _list.RemoveAt(index).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveRange(IEnumerable<T> items, IEqualityComparer<T> equalityComparer) => _list.RemoveRange(items, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveRange(int index, int count) => _list.RemoveRange(index, count).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> Replace(T oldValue, T newValue, IEqualityComparer<T> equalityComparer) => _list.Replace(oldValue, newValue, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> SetItem(int index, T value) => _list.SetItem(index, value).WithDeepEquality();
        public bool IsDefaultOrEmpty => _list.IsDefaultOrEmpty;

        public static ImmutableArrayWithDeepEquality<T> Empty = new(ImmutableArray<T>.Empty);

        #endregion

        #region IEnumerable

        IEnumerator IEnumerable.GetEnumerator() => (_list as IEnumerable).GetEnumerator();
        IEnumerator<T> IEnumerable<T>.GetEnumerator() => (_list as IEnumerable<T>).GetEnumerator();

        #endregion

        #region IEquatable

        public bool Equals(ImmutableArrayWithDeepEquality<T> other) => _list.SequenceEqual(other);

        public override bool Equals(object obj) => obj is ImmutableArrayWithDeepEquality<T> other && Equals(other);

        public static bool operator ==(ImmutableArrayWithDeepEquality<T>? left, ImmutableArrayWithDeepEquality<T>? right) => left is null ? right is null : left.Equals(right);

        public static bool operator !=(ImmutableArrayWithDeepEquality<T>? left, ImmutableArrayWithDeepEquality<T>? right) => !(left == right);

        public override int GetHashCode()
        {
            unchecked
            {
                return _list.Aggregate(19, (h, i) => h * 19 + i!.GetHashCode());
            }
        }


        #endregion
    }

    public static class ImmutableArrayWithDeepEqualityEx
    {
        public static ImmutableArrayWithDeepEquality<T> WithDeepEquality<T>(this ImmutableArray<T> list) => new(list);

        public static ImmutableArrayWithDeepEquality<T> ToImmutableArrayWithDeepEquality<T>(this IEnumerable<T> list) => new(list.ToImmutableArray());
    }
}

ImmutableArrayWithDeepEquality JsonConverter

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    public class JsonConverterForImmutableArrayWithDeepEqualityFactory : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableArrayWithDeepEquality<>);

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var elementType = typeToConvert.GetGenericArguments()[0];

            var arrayType = typeof(JsonConverterForImmutableArrayWithDeepEquality<>);

            var converter = (JsonConverter)Activator.CreateInstance(
                arrayType.MakeGenericType(elementType),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }

        private class JsonConverterForImmutableArrayWithDeepEquality<T> : JsonConverter<ImmutableArrayWithDeepEquality<T>>
        {
            public override ImmutableArrayWithDeepEquality<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartArray)
                {
                    throw new JsonException();
                }

                reader.Read();

                List<T> elements = new();

                while (reader.TokenType != JsonTokenType.EndArray)
                {
                    var value = JsonSerializer.Deserialize<T>(ref reader, options);

                    if (value is not null)
                    {
                        elements.Add(value);
                    }

                    reader.Read();
                }

                return elements.ToImmutableArrayWithDeepEquality();
            }

            public override void Write(Utf8JsonWriter writer, ImmutableArrayWithDeepEquality<T> value, JsonSerializerOptions options)
            {
                JsonSerializer.Serialize(writer, value.AsEnumerable(), options);
            }
        }
    }
}

ImmutableDictionaryWithDeepEquality

using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    [JsonConverter(typeof(JsonConverterForImmutableDictionaryWithDeepEqualityFactory))]
    public class ImmutableDictionaryWithDeepEquality<TKey, TValue> : IEquatable<ImmutableDictionaryWithDeepEquality<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IReadOnlyCollection<KeyValuePair<TKey, TValue>> where TKey : notnull
    {
        private readonly ImmutableDictionary<TKey, TValue> _dictionary;

        public ImmutableDictionaryWithDeepEquality(ImmutableDictionary<TKey, TValue> dictionary) => _dictionary = dictionary;

        #region ImmutableArray Implementation

        public TValue this[TKey index] => _dictionary[index];

        public int Count => _dictionary.Count;

        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Add(TKey key, TValue value) => _dictionary.Add(key, value).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> AddRange(IEnumerable<KeyValuePair<TKey, TValue>> pairs) => _dictionary.AddRange(pairs).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Clear() => _dictionary.Clear().WithDeepEquality();
        public ImmutableDictionary<TKey, TValue>.Enumerator GetEnumerator() => _dictionary.GetEnumerator();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Remove(TKey key) => _dictionary.Remove(key).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> RemoveRange(IEnumerable<TKey> keys) => _dictionary.RemoveRange(keys).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> SetItem(TKey key, TValue value) => _dictionary.SetItem(key, value).WithDeepEquality();
        public bool IsEmpty => _dictionary.IsEmpty;

        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> Empty = new(ImmutableDictionary<TKey, TValue>.Empty);

        #endregion

        #region IEnumerable

        IEnumerator IEnumerable.GetEnumerator() => (_dictionary as IEnumerable).GetEnumerator();
        IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator() => (_dictionary as IEnumerable<KeyValuePair<TKey, TValue>>).GetEnumerator();

        #endregion

        #region IEquatable

        public bool Equals(ImmutableDictionaryWithDeepEquality<TKey, TValue> other) => _dictionary.SequenceEqual(other);

        public override bool Equals(object obj) => obj is ImmutableDictionaryWithDeepEquality<TKey, TValue> other && Equals(other);

        public static bool operator ==(ImmutableDictionaryWithDeepEquality<TKey, TValue>? left, ImmutableDictionaryWithDeepEquality<TKey, TValue>? right) => left is null ? right is null : right is not null && left.Equals(right);

        public static bool operator !=(ImmutableDictionaryWithDeepEquality<TKey, TValue>? left, ImmutableDictionaryWithDeepEquality<TKey, TValue>? right) => !(left == right);

        public override int GetHashCode()
        {
            unchecked
            {
                return _dictionary.Aggregate(19, (h, i) => h * 19 + i.Key.GetHashCode() + (i.Value?.GetHashCode() ?? 0));
            }
        }

        #endregion
    }

    public static class ImmutableDictionaryWithDeepEqualityEx
    {
        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> WithDeepEquality<TKey, TValue>(this ImmutableDictionary<TKey, TValue> dictionary) where TKey : notnull => new(dictionary);

        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> ToImmutableDictionaryWithDeepEquality<TKey, TValue>(this IEnumerable<KeyValuePair<TKey, TValue>> list) where TKey : notnull => new(list.ToImmutableDictionary());
    }
}

ImmutableDictionaryWithDeepEquality JsonConverter

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    public class JsonConverterForImmutableDictionaryWithDeepEqualityFactory : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert) =>
            typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableDictionaryWithDeepEquality<,>);

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var keyType = typeToConvert.GetGenericArguments()[0];
            var valueType = typeToConvert.GetGenericArguments()[1];

            var dictionaryType = typeof(JsonConverterForImmutableDictionaryWithDeepEquality<,>);

            var converter = (JsonConverter)Activator.CreateInstance(
                dictionaryType.MakeGenericType(keyType, valueType),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }

        private class JsonConverterForImmutableDictionaryWithDeepEquality<TKey, TValue> : JsonConverter<ImmutableDictionaryWithDeepEquality<TKey, TValue>> where TKey : notnull
        {
            public override ImmutableDictionaryWithDeepEquality<TKey, TValue> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartArray)
                {
                    throw new JsonException();
                }

                reader.Read();

                Dictionary<TKey, TValue> elements = new();

                while (reader.TokenType != JsonTokenType.EndArray)
                {
                    var value = JsonSerializer.Deserialize<KeyValuePair<TKey, TValue>>(ref reader, options);

                    elements.Add(value.Key, value.Value);

                    reader.Read();
                }

                return elements.ToImmutableDictionaryWithDeepEquality();
            }

            public override void Write(Utf8JsonWriter writer, ImmutableDictionaryWithDeepEquality<TKey, TValue> value, JsonSerializerOptions options)
            {
                JsonSerializer.Serialize(writer, value.AsEnumerable(), options);
            }
        }
    }
}

ImmutableDeepEqualityTests

using System.Collections.Generic;
using Xunit;

namespace System.Collections.Immutable.Tests
{
    public class ImmutableDeepEqualityTests
    {
        [Fact]
        public void ArraysWithSameValues_AreConsideredEqual()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array1Copy = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            Assert.Equal(array1, array1Copy);
            Assert.True(array1 == array1Copy);
            Assert.False(array1 != array1Copy);
            Assert.True(array1.Equals(array1Copy));
        }

        [Fact]
        public void ArraysWithDifferentValues_AreConsideredNotEqual()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array2 = new int[] { 4, 5, 6 }.ToImmutableArrayWithDeepEquality();

            Assert.NotEqual(array1, array2);
            Assert.False(array1 == array2);
            Assert.True(array1 != array2);
            Assert.False(array1.Equals(array2));
        }

        [Fact]
        public void DictionariesWithSameValues_AreConsideredEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict1Copy = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.Equal(dict1, dict1Copy);
            Assert.True(dict1 == dict1Copy);
            Assert.False(dict1 != dict1Copy);
            Assert.True(dict1.Equals(dict1Copy));
        }

        [Fact]
        public void DictionariesWithDifferentKeys_AreConsideredNotEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict2 = new Dictionary<string, string> { { "KeyA", "1" }, { "KeyB", "2" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.NotEqual(dict1, dict2);
            Assert.False(dict1 == dict2);
            Assert.True(dict1 != dict2);
            Assert.False(dict1.Equals(dict2));
        }

        [Fact]
        public void DictionariesWithDifferentValues_AreConsideredNotEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict2 = new Dictionary<string, string> { { "Key1", "A" }, { "Key2", "B" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.NotEqual(dict1, dict2);
            Assert.False(dict1 == dict2);
            Assert.True(dict1 != dict2);
            Assert.False(dict1.Equals(dict2));
        }

        [Fact]
        public void RecordsUseDeepEquality()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array1Copy = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict1Copy = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);
            var model1Copy = new TestModel(array1Copy, dict1Copy);

            Assert.Equal(model1, model1Copy);
            Assert.True(model1 == model1Copy);
            Assert.False(model1 != model1Copy);
            Assert.True(model1.Equals(model1Copy));
        }

        [Fact]
        public void Records_ThatUseDeepEquality_CanSerialize()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);

            var json = System.Text.Json.JsonSerializer.Serialize(model1);

            Assert.Equal("{\"Ints\":[1,2,3],\"Dictionary\":[{\"Key\":\"Key1\",\"Value\":\"1\"}]}", json);
        }

        [Fact]
        public void Records_ThatUseDeepEquality_CanDeserialize()
        {
            var json = "{\"Ints\":[1,2,3],\"Dictionary\":[{\"Key\":\"Key1\",\"Value\":\"1\"}]}";

            var modelFromJson = System.Text.Json.JsonSerializer.Deserialize<TestModel>(json);

            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);

            Assert.Equal(model1, modelFromJson);
        }

        private sealed record TestModel(
            ImmutableArrayWithDeepEquality<int> Ints,
            ImmutableDictionaryWithDeepEquality<string, string> Dictionary);
    }
}

Conclusion

As you can see it's no small task to implement, support, and maintain. After looking at are own needs we found deep equality was only needed to mask upstream issues that produced duplicate values when duplicate values never needed to be created in the first place.


It looks like there is currently no such type available.

You can implement this yourself, but beware of the implications if you need this in a production-ready way. It's not so simple as it may seem at first sight, as this answer by @ryanholden8 demonstrates!

For my use case (and as a simplified example) I went along with this gist which decorates an IImutableList and can be used as follows:

var r1 = new SomeRecord(0, "test", new[] { 1, 2 }.ToImmutableList().WithValueSemantics());
var r2 = new SomeRecord(0, "test", new[] { 1, 2 }.ToImmutableList().WithValueSemantics());
Console.WriteLine(r1 == r2); // true

Obviously beware of the performance implications for very large lists.

Tags:

C#

C# 9.0