Back to Blog
C#

Implementing IComparable in C# for Reliable Sorting

c# icomparable: Learn how to implement IComparable<T> in C# for custom sorting, including CompareTo semantics, null handling, and consistency with Equals.

C#IComparableCompareToSortingLINQ
Illustration of two C# objects being compared with an ordering arrow, representing the IComparable comparison contract

c# icomparable requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The IComparable<T> interface in C# defines the natural ordering of a type. When a class or struct implements it, collection sorting methods and LINQ operators such as OrderBy can arrange instances without any external comparer. The interface itself is minimal, but the contract behind its single method carries several correctness requirements that are easy to miss.

What IComparable<T> Defines

The interface requires exactly one method:

public interface IComparable<T> { int CompareTo(T? other); }

The argument is nullable because the interface contract permits comparing against null. The method returns an integer that communicates the relative ordering of the current instance and the argument. Every built-in numeric type, string, DateTime, and Guid implements this interface, which is why List<int>.Sort() and OrderBy(x => x) work without extra configuration.

The Meaning of the Return Value

The return value of CompareTo follows a strict convention:

  • A negative value means the current instance precedes the argument.
  • Zero means the two instances are equal for ordering purposes.
  • A positive value means the current instance follows the argument.

This is the same convention used by int.CompareTo, string.CompareTo, and every other implementation in the base class library. The sign of the result is what matters, not the magnitude. Returning -5 and returning -1 produce the same ordering, although consistent magnitudes make debugging easier.

Implementing CompareTo for a Domain Type

Consider a Priority type that needs to sort by its level, with higher levels appearing first in an ascending sort. The implementation delegates to the underlying integer comparison but reverses the operands:

public sealed class Priority : IComparable<Priority> { public int Level { get; } public Priority(int level) { Level = level; } public int CompareTo(Priority? other) { if (other is null) { return 1; } return other.Level.CompareTo(Level); } }

The null check is mandatory because the parameter is nullable. Returning 1 for a null argument means the current instance sorts before null, which matches the convention used by most built-in types. The comparison itself uses int.CompareTo, but swaps the operands so that a higher level produces a negative result and therefore appears earlier in an ascending sort.

Sorting Collections with IComparable

Once a type implements IComparable<T>, standard collection operations work without additional setup:

var priorities = new List<Priority> { new Priority(3), new Priority(1), new Priority(2) }; priorities.Sort(); foreach (var priority in priorities) { Console.WriteLine(priority.Level); }

List<T>.Sort() uses the default comparer, which resolves to IComparable<T> when the element type implements it. LINQ's OrderBy behaves the same way. The same type can also serve as a dictionary key when consistent iteration order matters, although the hash code must then be aligned with the comparison logic.

When CompareTo Must Be Consistent with Equals

A subtle requirement is that CompareTo returning zero should agree with Equals returning true. If the two disagree, sorted collections and hash-based collections can produce inconsistent behavior, such as duplicate keys appearing in a HashSet or a Dictionary failing to find an existing key.

public sealed class Temperature : IComparable<Temperature> { public double Celsius { get; } public Temperature(double celsius) { Celsius = celsius; } public int CompareTo(Temperature? other) { if (other is null) { return 1; } return Celsius.CompareTo(other.Celsius); } public override bool Equals(object? obj) { return obj is Temperature other && Celsius.Equals(other.Celsius); } public override int GetHashCode() { return Celsius.GetHashCode(); } }

When both methods are implemented, they should use the same comparison basis. Note that double.CompareTo treats NaN specially, placing it before all other values. If Equals uses a simple == check, a NaN temperature would compare as equal to nothing while still having a defined sort position, which can create edge-case inconsistencies.

IComparable vs IComparer

IComparable<T> is implemented by the type itself and defines a single natural ordering. IComparer<T> is a separate class that provides an alternative ordering without modifying the original type. The two serve different purposes and are often confused.

public sealed class TemperatureByFahrenheit : IComparer<Temperature> { public int Compare(Temperature? x, Temperature? y) { if (ReferenceEquals(x, y)) { return 0; } if (x is null) { return -1; } if (y is null) { return 1; } double xFahrenheit = x.Celsius * 9.0 / 5.0 + 32.0; double yFahrenheit = y.Celsius * 9.0 / 5.0 + 32.0; return xFahrenheit.CompareTo(yFahrenheit); } }

Use IComparable<T> for the ordering that most callers expect, such as sorting employees by employee ID. Use IComparer<T> when multiple orderings exist, such as sorting by name versus by hire date, or when the type comes from an external library and cannot be modified. The two approaches can coexist: a type can implement IComparable<T> for its default ordering while callers pass an IComparer<T> to override it for a specific scenario.

Common Mistakes in CompareTo Implementations

The most frequent error is comparing fields without handling a null argument. Calling other.Level when other is null throws a NullReferenceException during sorting, which is especially confusing because the failure surfaces deep inside a framework method rather than at the comparison site.

Another mistake is returning a constant like -1 or 1 for all non-equal comparisons. This breaks the transitivity requirement that sorting algorithms depend on. If A > B and B > C, the algorithm expects A > C. A constant-based comparison can violate that expectation and produce an unstable or incorrect sort order.

A third mistake is comparing multiple fields inconsistently. When a type has both a primary and a secondary sort key, the comparison must chain the results:

public int CompareTo(Employee? other) { if (other is null) { return 1; } int departmentComparison = Department.CompareTo(other.Department); if (departmentComparison != 0) { return departmentComparison; } return Name.CompareTo(other.Name); }

Chaining ensures that employees in the same department are ordered by name, and the overall ordering remains transitive. Returning early only when the primary comparison is nonzero avoids the trap of letting the secondary key override the primary key.

Performance and Maintainability Considerations

Implementing IComparable<T> is cheap because the comparison logic is just method calls on existing fields. The main performance concern is avoiding allocations inside CompareTo. Boxing a value type or constructing a string for comparison on every call adds measurable overhead when sorting large collections. Comparing the underlying numeric or string fields directly avoids that cost.

For maintainability, keep the comparison logic in one place. If the natural ordering changes, only CompareTo needs to be updated. Duplicating the logic in multiple call sites creates drift when the rules change, and the inconsistency is often hard to detect because it only appears when two different code paths produce different orderings.

When a type is used as a dictionary key, the hash code and the comparison must remain aligned. Changing the comparison logic without updating GetHashCode can break dictionary lookups, because the hash code determines the bucket while the comparison determines equality within the bucket. The two methods should always be updated together.

c# icomparable: Practical Usage and Code Examples | RYUSLOG DEV