DeepEquals.SourceGeneration.Framework
1.0.0-beta04
dotnet add package DeepEquals.SourceGeneration.Framework --version 1.0.0-beta04
NuGet\Install-Package DeepEquals.SourceGeneration.Framework -Version 1.0.0-beta04
<PackageReference Include="DeepEquals.SourceGeneration.Framework" Version="1.0.0-beta04" />
<PackageVersion Include="DeepEquals.SourceGeneration.Framework" Version="1.0.0-beta04" />
<PackageReference Include="DeepEquals.SourceGeneration.Framework" />
paket add DeepEquals.SourceGeneration.Framework --version 1.0.0-beta04
#r "nuget: DeepEquals.SourceGeneration.Framework, 1.0.0-beta04"
#:package DeepEquals.SourceGeneration.Framework@1.0.0-beta04
#addin nuget:?package=DeepEquals.SourceGeneration.Framework&version=1.0.0-beta04&prerelease
#tool nuget:?package=DeepEquals.SourceGeneration.Framework&version=1.0.0-beta04&prerelease
DeepEquals.SourceGenerator
A Roslyn source generator that emits deep, by-value IEqualityComparer<T> implementations for a closed set of types.
Generated comparison cores are static, cycle-safe, compare instance storage rather than property getters,
and avoid steady-state allocations wherever the runtime allows.
- Installation
- Project setup
- Usage
- Attributes
- Options
- What is compared
- Exceptions and limits
- Trip hazards
- Diagnostics: every
DEQid, when it fires and how to fix it - Implementation: the equality relation, the generated code and the runtime library
Installation
<PackageReference Include="DeepEquals.SourceGenerator" Version="1.0.0-beta02" PrivateAssets="all" />
<PackageReference Include="DeepEquals.SourceGeneration.Framework" Version="1.0.0-beta02" />
Two packages, in every project that declares a context. DeepEquals.SourceGenerator is the generator, an analyzer with no runtime surface of its own.
DeepEquals.SourceGeneration.Framework is the library the generated code compiles and runs against, for netstandard2.0, netstandard2.1, net6.0, net8.0 and net10.0.
The consuming project must be C# and compile with Roslyn 4.3.1 or later (.NET SDK 6.0.400, Visual Studio 17.3). Generated source is C# 7.3-compatible; nullable annotations appear from C# 8.
PrivateAssets="all" on the generator keeps it to the project that declares it. Leaving the flag off is not an error;
it means every project downstream of yours also loads the generator. It emits nothing where no context is declared, so the cost is only analyzer load time.
Everything under Project setup applies to the project that declares the context.
| Consumer target | Runtime asset | Notes |
|---|---|---|
netstandard2.0, net472 |
netstandard2.0 | System.Buffers, System.Memory, System.Runtime.CompilerServices.Unsafe and System.IO.Hashing 10.0.12 flow transitively, so spans are available. Field access below net8.0 uses cached delegates. |
netstandard2.1, netcoreapp3.1 |
netstandard2.1 | System.Runtime.CompilerServices.Unsafe flows transitively. No System.IO.Hashing, which warns on these runtimes, so sequences of bit-block values keep the per-element path. |
net6.0, net7.0 |
net6.0 | System.IO.Hashing 8.0.0, the last release that supports these runtimes. Compiles and runs; not a run-time test tier. |
net8.0 |
net8.0 | System.IO.Hashing 10.0.12. [UnsafeAccessor] field access; generic declaring types still use delegates. |
net10.0 |
net10.0 | System.IO.Hashing 10.0.12. [UnsafeAccessor] for every field, including generic declaring types. |
net5.0 and earlier .NET Core versions are unsupported as direct consumers (the netstandard2.1 asset's trimming attributes conflict with the in-box ones, CS0433).
A netstandard2.1 library using this package still runs on them.
Use both packages at the same version. The generated code binds the framework surface of its own version, so the generator checks the referenced framework assembly and reports DEQ036, an error, when the versions differ. Nothing else in the generator adapts to an older or newer framework package.
Project setup
Same project. When the context and the types it compares live in one project, nothing is needed beyond the package reference.
Types in another project. A ProjectReference normally hands the compiler a reference assembly, from which private fields and auto-property backing fields are stripped.
The generator then reports DEQ017 for every class it would have to walk.
Set the MSBuild property CompileUsingReferenceAssemblies to false in the project that declares the context:
<PropertyGroup>
<CompileUsingReferenceAssemblies>false</CompileUsingReferenceAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Models/Models.csproj" />
</ItemGroup>
The property has no effect on a PackageReference whose package ships a ref/ assembly; register such types with [SimpleType] or [CustomEqualityComparer] instead.
Internal types in another project. A member whose type is internal to another assembly is DEQ003.
Add InternalsVisibleTo for the context's assembly in the declaring project, or ignore the member.
Projects targeting net472 or later The transitive System.Runtime.CompilerServices.Unsafe package needs binding redirects:
<PropertyGroup>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
</PropertyGroup>
netstandard2.0 and .NET Framework spans. The framework package brings System.Memory on this tier, so the generator emits Memory<T>/ReadOnlyMemory<T> shapes, span equality loops and the bit-block byte paths there too.
Hash loops over interface-typed collection views still use streaming and indexer/enumerator fallbacks, since the span helpers for them are absent from this asset.
Trimming and NativeAOT. Field access through [UnsafeAccessor] is trim- and AOT-safe. Where the delegate fallback is needed (below net8.0, and generic declaring types on net8.0),
the affected type's convenience property getter and GetEqualityComparer<T>() carry [RequiresUnreferencedCode]/[RequiresDynamicCode],
so the warning appears on access of that type and nowhere else. Safe types in the same context produce no warning.
Warnings as errors. Generated code is written to raise no warning of its own, with full nullable analysis and XML documentation on every public member.
A generated file disables a warning only where it has to name something your types use: an [Obsolete] type (CS0612, CS0618 or its custom id), an [Experimental] type or a preview feature, one #pragma line each, naming the symbol.
An [Obsolete] member is read through its storage, so it needs no suppression wherever [UnsafeAccessor] exists.
Usage
using DeepEquals.SourceGeneration;
[GenerateDeepEquals(typeof(Customer))]
[GenerateDeepEquals(typeof(Order))]
public partial class MyDeepEqualsContext : DeepEqualsContextBase { }
bool same = MyDeepEqualsContext.Customer.Equals(a, b);
int hash = MyDeepEqualsContext.Customer.GetHashCode(a);
IEqualityComparer<Order> orders = MyDeepEqualsContext.GetEqualityComparer<Order>();
The context must be a non-generic, non-abstract partial class deriving from DeepEqualsContextBase.
Registering a type registers its closure: member types, element/key/value types, tuple items, base classes and implemented interfaces. Every type in the closure gets:
- a nested
{Name}EqualityComparerwrapper with a singletonInstance, - a static convenience property
MyDeepEqualsContext.{Name}, - an entry behind
GetEqualityComparer<T>(), which throwsDeepEqualsMissingComparerExceptionfor a type outside the closure.
Derived types are not discovered. Register every runtime type a polymorphic member (object, an interface, an abstract or unsealed class) can hold;
an unregistered runtime type throws DeepEqualsUnknownTypeException at comparison time.
Seal your contract classes. An unsealed class is compared through a dispatch on its runtime type every time, because a derived instance may be behind it. A sealed class is compared by its members directly. There is no option to assume the declared type: sealing is how a class says it has no subclasses.
Shared registrations. Attributes on an abstract base context are inherited. A derived context must carry at least one
[GenerateDeepEquals] or a [DeepEqualsSourceGenerationOptions] on its own declaration to be discovered:
[GenerateDeepEquals(typeof(Customer))]
[SimpleType(typeof(Sku))]
public abstract class SharedContext : DeepEqualsContextBase { }
[GenerateDeepEquals(typeof(Invoice))] // adds a type
public partial class BillingContext : SharedContext { }
[DeepEqualsSourceGenerationOptions] // adds nothing; marks the class as a context
public partial class ReportingContext : SharedContext { }
Deriving one concrete context from another is unsupported.
Attributes
All attributes live in DeepEquals.SourceGeneration.
| Attribute | Target | Meaning |
|---|---|---|
[GenerateDeepEquals(typeof(T))] |
context, repeatable | Registers T as a closure root. Only a root marker: it never overrides a simple or custom rule for T. Registering both a base and a derived type equals registering the derived type. |
[DeepEqualsSourceGenerationOptions(...)] |
context, once | Per-context options (below). An empty attribute also marks a derived context. Options merge per property along the base chain; derived wins. |
[SimpleType(typeof(T))] |
context, repeatable | T and every type assignable to it is a leaf using its own default equality for the static type in use: a direct Equals(TStatic) call when TStatic implements IEquatable<TStatic> itself, otherwise EqualityComparer<TStatic>.Default. Registering an interface such as IInterface covers every implementing struct or class. typeof(S?) for a struct normalizes to S (DEQ028). A Base and a Derived rule overlap; the narrower is ignored (DEQ025). |
[CustomEqualityComparer(typeof(TComparer), handleNulls = false)] |
context, repeatable | Compares T, taken from TComparer's single IEqualityComparer<T>, with that comparer everywhere T or an assignable type appears. The instance comes from a public static Instance or Default member, else a public parameterless constructor. |
[CustomEqualityComparer(typeof(TComparer), "MemberName", handleNulls = false)] |
context, repeatable | Same, taking the instance from a named public static field, property or parameterless method. Abstract comparer classes are allowed: [CustomEqualityComparer(typeof(StringComparer), nameof(StringComparer.OrdinalIgnoreCase))]. |
[DeepEqualsIgnore] |
field, auto-property, field-keyword property, captured primary-constructor parameter |
Excludes that storage from comparison and hashing. |
[DeepEqualsIgnore(typeof(Declaring), "member")] |
context, repeatable | Excludes storage you cannot annotate, such as a private field of a base class in another assembly. The name resolves as a field first, otherwise as a property whose backing field is excluded. |
Custom comparer rules:
Tmay be any type, including a constructed collection such asDictionary<string, int>orIReadOnlyDictionary<string, int>; matching is instantiation-exact, with ordinary assignability (a comparer for an interface covers its implementations, including structs, which are boxed once per side).- With
handleNulls: falsethe library null rule runs first and the comparer sees only non-null values. WithhandleNulls: truethe comparer receives null and owns the whole contract. TandT?registrations for a struct are distinct. With onlyTregistered,T?checksHasValueand delegates. With onlyT?registered,Tis wrapped and delegated without boxing.- A custom rule beats a covering simple rule (
DEQ008). ABaserule coversDerived; registering both ignoresDerived(DEQ030). Two unrelated interface rules covering one type is an error (DEQ031). A rule forobjectis ignored (DEQ035). - Comparers and their factories must be thread-safe and return stable results throughout an operation. They are resolved lazily, once per process.
Options
Set on [DeepEqualsSourceGenerationOptions]. Invalid values warn (DEQ013) and fall back to the default.
| Option | Default | Range | Effect |
|---|---|---|---|
MaxSwitchCases |
12 | ≥ 1 | Exact dispatch cases above which a polymorphic core switches from an if chain to a dictionary lookup and switch; the common built-in leaves and up to this many of the closure's own types stay in the chain ahead of the lookup. |
MaxUnorderedCollisionRun |
64 | 1..512 | Longest run of equal-hash entries a set or dictionary comparison resolves by exact matching; longer runs throw. |
MaxComparisonPairs |
1,000,000 | 1..2^29 | Distinct (kind, left, right) triples one comparison may retain; the next novel triple throws. Bounds memory as well as cyclic work. |
MaxBinaryExpressionArity |
64 | ≥ 1 | Largest generated && chain; longer member lists are split into consecutive if statements. |
StructPassByValueMaxByteSize |
8 | ≥ 0 | Structs whose estimated field size is at most this are passed by value to generated cores; larger ones by in. |
ExcludeInterfacesByPrefix |
empty | namespace prefixes | Interfaces whose namespace equals a prefix or starts with prefix + "." are skipped by the automatic base/interface crawl, as System already is. Does not affect explicit roots or member types. |
CycleHandling |
Graph |
Graph, Path, Tree |
How a comparison remembers where it has been, and so what a recursive type costs. See Choosing a cycle mode. |
MaxDepth |
512 | 1..1,000,000 | Under Tree only: the guarded nesting depth past which a comparison or hash throws. Linked lists are walked in a loop and do not count against it. Setting it under another mode warns (DEQ037). |
MatchingHashDepth |
4 | 1..16 | Under Graph and Path: how many payload edges into a recursive type the fingerprint that buckets set and dictionary entries follows, where the public hash follows one. Setting it under Tree warns (DEQ037). |
Choosing a cycle mode
Graph, the default, retains every pair of objects it meets at a cycle guard for the whole comparison. A pair met again is assumed equal, so real cycles terminate and a subgraph shared by many parents is compared once. Each guarded pair costs a table probe, and a wide tree retains a pair per node.Pathretains only the ancestors of the pair being compared: each pair leaves the table when its comparison returns. Real cycles still terminate and answers are identical toGraph, and the table stays as small as the nesting is deep. A shared subgraph is compared once per path that reaches it, which can be exponential on heavily shared graphs.Treeretains nothing. One depth counter bounds the traversal, there is no pair table, no pool rental and no pair budget, and the hash walks the whole value instead of one level into a recursive type. It is the mode for deserialized data, which cannot hold cycles. The traversal is bounded, not the input validated. The same reference is equal before any traversal, a cyclic child shared by both sides is met as one reference, and a member that differs before a cycle decides first. A cycle the traversal does enter throwsDeepEqualsComplexityExceptiononce the depth passesMaxDepth, or at once from a linked-list loop. The exception names the type whose guard ran out of depth, which is not necessarily the type that closes the cycle.
Hash values differ between modes, as they already differ between processes; persist none of them.
What is compared
- Storage, not getters. Every instance field, public or private, including auto-property,
field-keyword and primary-constructor capture storage, across the whole base chain. Computed properties, static fields, delegates and events are not compared. Selected model getters are never invoked. - Strings ordinally.
charandboolby value. - Floating point bitwise:
NaNequals only the sameNaNbits,+0differs from-0. The same rule applies insideComplex, theSystem.Numericsvectors and matrices, andPointF/SizeF/RectangleF. decimalby its four representation words, so1.0mdiffers from1.00mand0mfrom-0m.DateTimeby its complete 64-bit storage: ticks, kind and the hidden ambiguous-daylight-saving flag.DateTimeOffsetby ticks and offset.Uriby ordinalOriginalStringplus the absolute/relative flag.- Enums as their declared underlying integer, all bits. 64-bit and wider leaves,
Guidand strings hash through a per-process seeded mix rather than the BCL's xor fold. - Other built-in leaves (
Guid,TimeSpan,DateOnly,TimeOnly,Version,Type,IPAddress,CultureInfo,TimeZoneInfo,Encoding,Index,Range,BigInteger,Half,Int128,Rune,Color,Point,Size,Rectangle) and[SimpleType]leaves by their own equality, callingIEquatable<T>.Equalsdirectly where the type implements it.Regexby reference, withDEQ022unless a custom comparer is registered. - Structs by members, ignoring layout and padding. Nullable values by
HasValuethen payload. - Sequences of bit-block values compare as one block of memory and hash with seeded XxHash3 over their bytes, which is exactly the rules above for these types. A bit-block value is an integer other than
boolandnint, a floating-point value,decimal,Guid,DateTime,TimeSpan,Int128, an enum, or a source-declared struct of such fields with no padding and every field compared. Every container shape of the declared type hashes alike; a shape without a span is copied into a pooled buffer first. KeyValuePair<K,V>,ValueTupleandTupleitem by item; tuple element names do not participate.- Ordered collections (
T[],List<T>,ImmutableArray<T>,ArraySegment<T>,Memory<T>,IList<T>,IReadOnlyList<T>,IReadOnlyCollection<T>,IEnumerable<T>) element by element, by the declared static type. AHashSet<T>behindIEnumerable<T>is an ordered sequence. - Sets (
ISet<T>,IReadOnlySet<T>) and dictionaries (IDictionary<K,V>,IReadOnlyDictionary<K,V>) as unordered multisets under this library's equality of elements and key-value pairs. The collection's own comparer is never consulted. - Polymorphic members (
object, interfaces, abstract or unsealed classes) require equal runtime types, then dispatch to the concrete type's comparison. Collections and leaves behind such a member match by shape rather than exact runtime type, soHashSet<T>equalsSortedSet<T>behindISet<T>. - Cycles terminate with coinductive semantics under
GraphandPath: a pair already under comparison is assumed equal, soA→B→Aequals its unrolled form. Shared and copied subgraphs compare equal, and equal graphs always hash equal. UnderTreea cycle the traversal enters throws; see Choosing a cycle mode. - Null and empty collections differ; null hashes to 0, empty to a nonzero value.
Register a custom comparer for any type where the BCL's normalized equality is what you want.
Exceptions and limits
| Exception | When |
|---|---|
DeepEqualsUnknownTypeException |
A polymorphic member holds a runtime type outside the closure, on both sides with the same type, or on any hash. Two different unregistered types compare unequal without throwing. |
DeepEqualsMissingComparerException |
GetEqualityComparer<T>() for a T outside the closure. |
DeepEqualsComplexityException |
MaxComparisonPairs exceeded, a set/dictionary hash-collision run longer than MaxUnorderedCollisionRun, or under Tree a traversal deeper than MaxDepth or a linked list that loops. |
InsufficientExecutionStackException |
Recursion deep enough to threaten the thread stack. Checked at the Equals entry of a comparer whose closure has a cycle and at every cycle guard, in hashing too under Tree. An acyclic closure is as deep as its declarations and is not checked. |
InvalidOperationException |
A collection enumerated more or fewer elements than its advertised Count. |
Neither budget bounds cumulative work or elapsed time; nested unordered trials may repeat comparisons. Allocation-free steady state holds for acyclic graphs and known collections. Cyclic graphs beyond 8 retained pairs, every set/dictionary comparison, and the hash of a bit-block sequence that has no span rent pooled arrays. Under Path the state holds only the ancestors of the current pair, and under Tree there is no state at all. Near the default pair budget a spilled Graph state can hold about 36 MB of pooled arrays: 24 MB of pairs, an 8 MB index and 4 MB of cached pair hashes.
Trip hazards
Deliberate limits. The library compares plain object graphs it can see completely; anything else is solved with [CustomEqualityComparer].
- Collection comparers are ignored. Two case-insensitive
Dictionary<string, int>holding{"A": 1}and{"a": 1}are unequal. Register a comparer for the key type, or for the whole constructed dictionary type. - The declared view is the semantics. Changing a member from
ISet<T>toIEnumerable<T>changes it from unordered to ordered. - Subclasses of BCL collections are compared as their base storage; a collection-shaped user type with extra fields is compared as the collection (
DEQ027). - Lazy or single-use enumerables may be enumerated separately for equality and hashing (
DEQ004). - Unknown runtime types are detected only for equal-typed pairs and hashes; do not rely on the exception to find a missing registration.
ImmutableArray<T>behind a covariant view (ImmutableArray<string>asIReadOnlyList<object>) is not recognized; a default value may throw onCount.- Fixed buffers and
[InlineArray]structs are rejected (DEQ032); multi-dimensional arrays are rejected (DEQ014);dynamicmembers are ignored (DEQ023). - Reference-assembly types cannot be walked (
DEQ017,DEQ026); see Project setup. - Structs covered by an interface custom rule are boxed once per side so the direct and boxed paths agree.
- Concurrent mutation during a comparison, code weaving and proxies are unsupported.
- Partial BCL polyfills (a
System.HalfwithoutBitConverter.HalfToInt16Bits) fail with an ordinary compile error in generated code.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 is compatible. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Buffers (>= 4.6.1)
- System.IO.Hashing (>= 10.0.12)
- System.Memory (>= 4.6.3)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2)
-
.NETStandard 2.1
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2)
-
net10.0
- System.IO.Hashing (>= 10.0.12)
-
net6.0
- System.IO.Hashing (>= 8.0.0)
-
net8.0
- System.IO.Hashing (>= 10.0.12)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on DeepEquals.SourceGeneration.Framework:
| Package | Downloads |
|---|---|
|
DeepEquals.SourceGenerator
A Roslyn source generator that emits deep, by-value IEqualityComparer<T> implementations for a closed set of types: cycle-safe, allocation-conscious, exact-representation leaves, and unordered set/dictionary semantics. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-beta04 | 117 | 9/21/2026 |
| 1.0.0-beta03 | 122 | 9/14/2026 |
| 1.0.0-beta02 | 223 | 9/9/2026 |
| 1.0.0-beta01 | 83 | 9/9/2026 |