A set of common, strongly typed, collection types which support record equality.
$ dotnet add package Lod.RecordCollectionsGeneric collections that implement value-based equality for use with C# records. Drop-in replacements for standard collections that work correctly with record equality comparison.
All collection types reside in the System.Collections.Generic namespace:
List<T>Dictionary<TKey, TValue>HashSet<T>Stack<T>Queue<T>record NumberInfo(IList<int> Numbers);
// Standard collections fail equality checks
var nums1 = new NumberInfo() { Numbers = new List<int>() { 1, 2, 3 } };
var nums2 = new NumberInfo() { Numbers = new List<int>() { 1, 2, 3 } };
Console.WriteLine(nums1 == nums2); // False - uses instance hash code
// Record collections pass equality checks
var nums3 = new NumberInfo() { Numbers = new RecordList<int>() { 1, 2, 3 } };
var nums4 = new NumberInfo() { Numbers = new RecordList<int>() { 1, 2, 3 } };
Console.WriteLine(nums3 == nums4); // True - uses value-based hash codeC# records provide value-based equality comparison for reference types by examining the hash code of each property. This works great for structural data types, but collections in .NET are reference types that use instance-based hash codes. When you include a standard collection in a record, equality comparisons fail because the collection's hash code is based on the instance, not its contents.
Record Collections solve this by providing value-based equality for collections, allowing records containing collections to correctly compare based on their contents rather than instance identity. The collections behave identically to their parent classes (e.g., updating a RecordList<T> while enumerating still throws InvalidOperationException).
Each Record Collection inherits directly from its corresponding standard collection type, making them true drop-in replacements. You can use them anywhere you would use the standard collection:
// Works with existing interfaces and base types
IList<int> standardList = new RecordList<int>();
List<int> list = new RecordList<int>();
ICollection<int> collection = new RecordSet<int>();
// All standard methods and properties work identically
var recordList = new RecordList<int> { 1, 2, 3 };
recordList.Add(4);
recordList.Remove(2);
var count = recordList.Count;
var first = recordList[0];This allows you to utilized Record Collections without changing existing data models, interface contracts, or other other cascading code while also allowing you to do so without impact as the methods, properties, and behaviors of the collections are identical to their base types.
Record Collections are implemented as record class types, which means they support cloning via the with expression. When a collection is cloned, it attempts to clone each underlying element to create an entirely new collection:
var original = new RecordList<Person>
{
new Person("Alice"),
new Person("Bob")
};
var cloned = original with { }; // Creates new collection with cloned elements
// If Person is a record, cloned contains new Person instances
// If Person is a regular class, cloned retains the original Person instancesCustomize collection and element cloning by overriding the cloner delegate:
// .NET 6.0 or greater
IReadOnlyRecordCollection.ElementCloner = obj => MyCustomCloner.Clone(obj, deep: true);
// .NET Framework 4.8 or .NET Standard 2.0
RecordCollectionCloner.ElementCloner = obj => MyCustomCloner.Clone(obj, deep: true);The RecordEnumerable class (in the System.Linq namespace) provides extension methods similar to Enumerable, but for creating Record Collections from any IEnumerable<T>. These methods mirror the standard LINQ ToList(), ToDictionary(), etc., but return Record Collections instead:
RecordList<T> from an IEnumerable<T>RecordDictionary<TKey, TValue> from an IEnumerable<T> and key/value delegate selectors.RecordSet<T> from an IEnumerable<T>RecordStack<T> from an IEnumerable<T>RecordQueue<T> from an IEnumerable<T>var numbers = Enumerable.Range(1, 10).Select(i => new Number(i));
// Convert to Record Collections
var recordList = numbers.ToRecordList();
var recordSet = numbers.ToRecordSet();
var recordStack = numbers.ToRecordStack();
var recordQueue = numbers.ToRecordQueue();
// Create RecordDictionary with key selector
var recordDict = numbers.ToRecordDictionary(n => n.Value);
// Create RecordDictionary with key and element selectors
var recordDict2 = numbers.ToRecordDictionary(
keySelector: n => n.Value,
elementSelector: n => n with { Value = n.Value * 2 }
);All methods require elements to implement IEquatable<T> (value or record-like types) and follow the same null-handling and exception behavior as their standard LINQ counterparts.
You can customize how record collections compare equality by using a custom comparer. There are two ways to specify a comparer:
You can set a default comparer that will be used by all record collections created without explicitly specifying a comparer:
// .NET 6.0 or greater
IReadOnlyRecordCollection.DefaultComparer = new MyCustomComparer();
// .NET Framework 4.8 or .NET Standard 2.0
RecordCollectionComparer.Default = new MyCustomComparer();All record collection types support constructors that accept an IRecordCollectionComparer parameter, allowing you to specify a comparer for individual instances:
var customComparer = new MyCustomComparer();
// Empty collection with custom comparer
var list = new RecordList<int>(customComparer);
// Collection from existing data with custom comparer
var stack = new RecordStack<int>([1, 2, 3], customComparer);
// Collection with capacity and custom comparer
var queue = new RecordQueue<int>(capacity: 100, customComparer);When a comparer is not provided, collections will use the default comparer (either the global default or RecordCollectionComparer.Default).
Hash Code Computation: The library computes hash codes by iterating through all elements in the collection. For ordered collections (lists, queues, stacks), each element's hash is combined with its position index. For unordered collections (sets, dictionaries), element hashes are combined using sum and xor operations. This results in O(n) time complexity where n is the number of elements.
Equality Comparison: Equality comparisons first check the collection count (O(1) fast path). If counts match, the implementation performs element-by-element comparison:
SetEquals which compares all elements regardless of orderBoth hash code computation and equality comparison are O(n) operations. While the count check provides a fast path for unequal-sized collections, comparing large collections with identical sizes requires examining every element.
Recommendations: The library is intended to provide a default but extendable implementation of common collections. It works great for data models with small to medium-sized collections (typically hundreds to low thousands of elements). When used with very large collections (hundreds of thousands or millions of elements), performance will deteriorate, especially when:
For large-scale scenarios, consider using custom comparers that implement caching or other optimization strategies.
Note: See the Cloning section for details on how collection cloning works.