A C# source generator that writes Equals, GetHashCode, and equality operators for you, with attribute based control over how each member is compared.
- Generates
Equals(object),Equals(T), andGetHashCode()overrides - Implements
IEquatable<T>and the==/!=operators - Supports
class,record, andstructtypes - Per member comparer selection through attributes
- Built-in comparers: string, sequence, dictionary, set, reference, and custom
- Works on properties and fields
- No runtime dependencies; the package is compile time only
dotnet add package Equatable.GeneratorThe generator emits its own attributes, so nothing needs to flow to consumers of your library. Mark the reference as private to keep it out of your package dependencies:
<PackageReference Include="Equatable.Generator" PrivateAssets="all" />- Roslyn 4.14 or later. In practice: Visual Studio 2022 17.14+, Visual Studio 2026+, a current Rider release, or the .NET SDK 9.0.300 or newer.
- Project C#
LangVersion8.0 or higher
Mark a partial type with [Equatable]. The generator creates the matching partial with equality members for all public properties and fields.
using Equatable.Attributes;
[Equatable]
public partial class Product
{
public int Id { get; set; }
public string Name { get; set; } = null!;
}var first = new Product { Id = 1, Name = "Widget" };
var second = new Product { Id = 1, Name = "Widget" };
Console.WriteLine(first == second); // True
Console.WriteLine(first.Equals(second)); // True
Console.WriteLine(first is IEquatable<Product>); // TrueFor class and struct types the generator emits IEquatable<T>, both Equals methods, GetHashCode, and the == / != operators. For record types it emits the strongly typed Equals and GetHashCode only, because the compiler supplies the rest.
| Member type | Default behavior |
|---|---|
| Value types | The == operator, avoiding boxing and comparer lookups |
string types |
StringComparer.Ordinal (same as string.Equals) |
| Everything else | EqualityComparer<T>.Default |
Use the attributes below to change the behavior of an individual member.
All attributes live in the Equatable.Attributes namespace.
| Attribute | Applies to | Behavior |
|---|---|---|
[Equatable] |
class, record, struct | Generates the equality members for the type |
[IgnoreEquality] |
property, field | Excludes the member from Equals and GetHashCode |
[StringEquality(StringComparison)] |
property, field | Uses the StringComparer matching the supplied StringComparison |
[SequenceEquality] |
property, field | Compares elements in order with Enumerable.SequenceEqual; order affects the hash code |
[DictionaryEquality] |
property, field | Compares entry counts and per key values; order independent |
[HashSetEquality] |
property, field | Compares contents with ISet<T>.SetEquals; order independent |
[ReferenceEquality] |
property, field | Compares with Object.ReferenceEquals and hashes with RuntimeHelpers.GetHashCode |
[EqualityComparer(Type, Property)] |
property, field | Uses the comparer returned by the named static property on the given type |
[Equatable]
public partial class Document
{
[EqualityComparer(typeof(TrimmedComparer), nameof(TrimmedComparer.Instance))]
public string? Title { get; set; }
}
public sealed class TrimmedComparer : IEqualityComparer<string?>
{
public static TrimmedComparer Instance { get; } = new();
public bool Equals(string? x, string? y)
=> string.Equals(x?.Trim(), y?.Trim(), StringComparison.Ordinal);
public int GetHashCode(string? obj)
=> obj?.Trim().GetHashCode() ?? 0;
}[Equatable]
public partial class UserImport
{
[StringEquality(StringComparison.OrdinalIgnoreCase)]
public string EmailAddress { get; set; } = null!;
public string? DisplayName { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public DateTimeOffset? LockoutEnd { get; set; }
public DateTimeOffset? LastLogin { get; set; }
[IgnoreEquality]
public string FullName => $"{FirstName} {LastName}";
[HashSetEquality]
public HashSet<string>? Roles { get; set; }
[DictionaryEquality]
public Dictionary<string, int>? Permissions { get; set; }
[SequenceEquality]
public List<DateTimeOffset>? History { get; set; }
}Records, including positional records, are supported. Use the property: target so the attribute lands on the generated property:
[Equatable]
public partial record StatusRecord(
int Id,
[property: StringEquality(StringComparison.OrdinalIgnoreCase)] string Name,
string? Description,
int DisplayOrder,
bool IsActive,
[property: SequenceEquality] List<string> Versions
);Structs work the same way:
[Equatable]
public partial struct Coordinate
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}- The type must be declared
partial. - Nested types are supported as long as every containing type is also
partial. - The attributes are conditional on the
EQUATABLE_GENERATORsymbol, so they leave no trace in the compiled output.
Issues and pull requests are welcome on GitHub.