List<T>.Sort() and Array.Sort() don't know how to compare your custom type — until you tell them, once, with a single method.
You've called .Sort() on a List<int> or List<string> without a second thought — it just works, because .NET already knows how to compare numbers and strings. Now try it on a list of your own type:
public class Product { public string Name = ""; public decimal Price; }
List<Product> products = [ new() { Name = "Widget", Price = 9.99m }, new() { Name = "Anvil", Price = 149.99m } ];
products.Sort(); // InvalidOperationException at runtime:
// "Failed to compare two elements in the array."This doesn't fail to compile — it fails at runtime, with a somewhat cryptic message. The real problem: Sort() needs some way to decide "which of these two Products comes first?" and nothing you've written tells it how. int and string already answer that question, because they implement an interface called IComparable<T> — and once your own types do too, sorting them becomes just as effortless.
In this lesson, you'll implement IComparable<T> to give a custom type a natural ordering, see exactly how List<T>.Sort() and Array.Sort() use it, and briefly contrast it with IComparer<T> — the interface you met in passing back in the covariance lesson.
IComparable<T> is how a type declares its own natural ordering — "given another instance of me, I can tell you whether I come before it, after it, or we're the same." It's the single method that int, string, DateTime, and every other built-in sortable type implement, and it's exactly the method missing from the broken Product example above.
IComparable<T> is a generic interface with exactly one member:
public interface IComparable<in T>
{
int CompareTo(T? other);
}CompareTo returns an int whose sign — not its exact value — carries the meaning:
other.other, for ordering purposes.other.Notice the in T — this is the contravariant interface you already met in the covariance lesson, which is exactly why it made sense as one of that lesson's running examples.
List<T>.Sort() and Array.Sort() are generic — the exact same sorting algorithm has to work correctly for a List<int>, a List<string>, and a List<Product>, without the sorting code itself knowing anything about what a Product even is. A sorting algorithm's entire job boils down to repeatedly asking "which of these two elements comes first?" — but it has no way to answer that question for an arbitrary type on its own; Price > Price means something for two decimals, but the algorithm doesn't know to compare Price at all, let alone that it should.
IComparable<T> is exactly the interface constraint you met two lessons ago in the constraints module — a contract the sorting algorithm can rely on without knowing anything about the type itself. Once Product implements IComparable<Product>, Sort() simply calls CompareTo on pairs of elements as many times as its algorithm needs, and lets your implementation decide what "comes first" actually means for a Product.
products.Sort();
↓
Sort's algorithm repeatedly picks two elements, a and b,
and needs to know: does a come before or after b?
↓
calls a.CompareTo(b)
↓
your CompareTo runs: Price.CompareTo(other.Price)
↓
returns negative / zero / positive
↓
Sort's algorithm uses that answer to decide where to place them
↓
repeats until the whole list is ordered
public class Product : IComparable<Product>
{
public string Name { get; init; } = "";
public decimal Price { get; init; }
// ...
}
public int CompareTo(Product? other)
{
if (other is null) return 1; // by convention, anything comes "after" null
return Price.CompareTo(other.Price); // decimal already implements IComparable<decimal>
}
decimal, string, DateTime, ...) already implements IComparable<T> itself.products.Sort(); // now works — ascending by Price
Array.Sort(productArray); // same story for arrays
Sort() checks whether the element type implements IComparable<T>, and if it does, uses it automatically.Min()/Max() (as seen in the constraints lesson's Catalog<T> example), SortedSet<T>, SortedDictionary<TKey,TValue>, and the comparison operators generated for types like DateOnly all rely on the same interface — implement it once, and all of these work correctly with no further effort.public class Product : IComparable<Product>
{
public string Name { get; init; } = "";
public decimal Price { get; init; }
public int CompareTo(Product? other) =>
other is null ? 1 : Price.CompareTo(other.Price);
public override string ToString() => $"{Name}: {Price:C}";
}
List<Product> products =
[
new() { Name = "Anvil", Price = 149.99m },
new() { Name = "Widget", Price = 9.99m },
new() { Name = "Gadget", Price = 24.99m }
];
products.Sort(); // works now — CompareTo drives the ordering
foreach (var p in products) Console.WriteLine(p);
// Widget: $9.99
// Gadget: $24.99
// Anvil: $149.99Code → Meaning → Result:
CompareTo delegates entirely to decimal.CompareTo, so Product's ordering is just "whichever Price is smaller" — no manual comparison logic needed.products.Sort(), which threw at runtime before, now works exactly like sorting a list of int or string.Product type has now defined for itself.Sorting a product catalog by a multi-part rule — in stock first, then by price — is a realistic scenario where CompareTo needs to combine more than one field, in priority order.
public class CatalogProduct : IComparable<CatalogProduct>
{
public string Name { get; init; } = "";
public decimal Price { get; init; }
public bool InStock { get; init; }
public int CompareTo(CatalogProduct? other)
{
if (other is null) return 1;
// In-stock items should sort first — compare that dimension first
int stockComparison = other.InStock.CompareTo(InStock); // note: reversed, so true (in stock) sorts first
if (stockComparison != 0) return stockComparison;
// Only fall back to price when stock status is the same
return Price.CompareTo(other.Price);
}
}
List<CatalogProduct> catalog =
[
new() { Name = "Anvil", Price = 149.99m, InStock = false },
new() { Name = "Widget", Price = 9.99m, InStock = true },
new() { Name = "Gadget", Price = 24.99m, InStock = true }
];
catalog.Sort();
foreach (var p in catalog) Console.WriteLine($"{p.Name} (in stock: {p.InStock}): {p.Price:C}");
// Widget (in stock: True): $9.99 ← in-stock items first, cheapest first
// Gadget (in stock: True): $24.99
// Anvil (in stock: False): $149.99 ← out-of-stock, regardless of priceThis is the pattern real production code uses constantly: compare the highest-priority field first, and only fall through to the next field when the first comparison is a tie (0) — exactly the same technique behind LINQ's OrderBy().ThenBy(), which you'll meet properly in a later module.
Think of CompareTo as a height-measuring stick at a theme park's line-sorting station. The stick doesn't know anything about the people themselves — their names, their favorite rides, anything — it can answer exactly one question: "given two people, who's taller?" That single answer is enough for a queue attendant to sort an entire line from shortest to tallest, one comparison at a time, without ever needing to know anything else about anyone.
List<T>.Sort() is the queue attendant — it doesn't know what a Product is, but as long as CompareTo can answer "which of these two comes first," that's all the information the sorting algorithm ever needs to put the whole list in order.
Product.public class NameComparer : IComparer<Product>
{
public int Compare(Product? x, Product? y) =>
string.Compare(x?.Name, y?.Name, StringComparison.Ordinal);
}
products.Sort(new NameComparer()); // overrides the natural ordering, just for this call
Product's own CompareTo at all. You already met this exact interface, and its contravariance, in the covariance and contravariance lesson.IComparable<T> covers the single most obvious one, and IComparer<T> lets you supply as many alternatives as you need, without touching the type itself. Both feed into the same overloads of Sort() — one with no argument, one accepting a comparer.CompareTo's exact return value doesn't matter — only its sign doesA common misreading is expecting CompareTo to return something meaningful like "the difference" between two values. It doesn't have to — -1, -100, and int.MinValue all mean exactly the same thing: "comes before." Delegating to decimal.CompareTo or string.Compare, as shown throughout this lesson, is the easiest way to get a correct sign without worrying about the magnitude at all.
2. CompareTo returning 0 is about ordering, not necessarily full equalityIt's tempting to assume "CompareTo returns 0" and "Equals returns true" always agree — .NET's own documented guidance is that they generally should, for consistency, but they answer conceptually different questions. Equals asks "are these the same value?"; CompareTo asks "should these be treated as tied for ordering purposes?" The multi-field example above returns 0 from the stock comparison when both are in stock, purely to fall through to the price comparison — that's not a claim the two products are equal.
IComparable<T> doesn't give you < and > automaticallyCompareTo is a method call, not an operator — product1.CompareTo(product2) > 0 works, but product1 > product2 still won't compile unless you separately overload the comparison operators (<, >, <=, >=) yourself, typically by delegating to CompareTo inside each operator.
Sort() without implementing IComparable<T> at all Wrong — throws InvalidOperationException at runtime, exactly as shown in the Hook:
public class Product { public decimal Price; } // no IComparable<T>
products.Sort(); // runtime exception Correct — implement the interface, or pass an explicit IComparer<T> if you'd rather not modify the type:
public class Product : IComparable<Product>
{
public decimal Price;
public int CompareTo(Product? other) => Price.CompareTo(other?.Price ?? 0);
}CompareTo that isn't transitive Writing comparison logic where a < b and b < c don't reliably imply a < c (for example, comparing by an unstable or randomly-changing value) — sorting algorithms assume transitivity, and violating it produces unpredictable, sometimes infinite-looping sort results. Base CompareTo on stable, well-defined fields, ideally by delegating to already-correct CompareTo implementations like decimal's or string's.
null in CompareTo return Price.CompareTo(other.Price); without a null check throws a NullReferenceException if other is null. By convention (and as every example in this lesson shows), a non-null instance is considered to come after null — return a positive number when other is null, exactly as string.CompareTo itself does.
Sort(), Min()/Max(), and sorted collections to work on your type with zero extra configuration.IComparable<T> for the one ordering that's genuinely "natural" for your type — the one most callers would expect by default. For every other ordering someone might reasonably want, write a separate IComparer<T> instead of trying to cram multiple orderings into one CompareTo.
Sort() and Array.Sort() use CompareTo automatically once your type implements IComparable<T>.0).IComparer<T> for every alternative.
IComparable<T> is a single-method interface — CompareTo(T? other) — that defines a type's natural ordering.int is all that matters: negative (before), zero (tied), positive (after).List<T>.Sort() and Array.Sort() use CompareTo automatically once your type implements the interface — no configuration needed.IComparer<T> is the interface to reach for when you need an alternative ordering — one type can have exactly one IComparable<T>, but as many IComparer<T> implementations as you need.You've given a custom type a natural ordering and connected it to Sort(). Let's check your understanding.
1. Why does List<Product>.Sort() throw InvalidOperationException at runtime when Product doesn't implement IComparable<Product>, rather than failing to compile?
Correct: A
Why A is correct: The parameterless overload of Sort() doesn't constrain its type parameter to IComparable<T> at compile time (a deliberate design choice, since not every use of List<T> needs sorting) — so the check happens only when Sort() actually needs to compare two elements at runtime and finds no usable comparison.
Why B is incorrect: This is standard, documented, intentional .NET behavior — not a defect awaiting a fix.
Why C is incorrect: List<T>.Sort() works fine — the exact same underlying issue and fix apply equally to Array.Sort().
Why D is incorrect: A parameterless constructor is unrelated to sorting — Sort() needs comparison capability, not construction capability.
Reinforcement: Not every generic capability is checked at compile time — some, like this one, are only discoverable when the operation actually needs them at runtime.
2. A CompareTo implementation returns -5 when comparing product A to product B. What does this mean?
Correct: B
Why B is correct: As covered in "Common Confusion," only the sign of CompareTo's return value matters — negative means "comes before." The magnitude, -5 versus -1 versus int.MinValue, is not meaningful in any general contract sense.
Why A is incorrect: This assumes the return value directly encodes a numeric difference — true only coincidentally for implementations that happen to delegate straight to a numeric field's own CompareTo, but not a general rule.
Why C is incorrect: A negative return value is a completely normal, valid, expected result — not an error.
Why D is incorrect: Equal (tied) ordering is signaled by exactly 0, not by a negative number.
Reinforcement: Treat CompareTo's return value purely by its sign — negative, zero, or positive — never by its exact numeric value.
3. You need to sort a List<Product> by name in one part of your application, and by price (its existing natural ordering via IComparable<Product>) in another. What's the best approach?
Correct: B
Why B is correct: As shown in "Under the Hood," this is exactly the division of labor between the two interfaces — IComparable<T> covers the one natural ordering, and any number of IComparer<T> implementations cover every alternative, without disturbing the type's own CompareTo.
Why A is incorrect: This would break every other part of the application relying on the existing price-based natural ordering — unnecessary when a comparer solves the problem cleanly.
Why C is incorrect: Sorting the same type multiple ways is a routine, well-supported scenario — exactly what IComparer<T> exists for.
Why D is incorrect: This needlessly duplicates the entire type just to support a different sort order — far more code than necessary.
Reinforcement: Multiple orderings for the same type are handled by adding comparers, not by rewriting or duplicating the type.
You now know how to give your own types a natural ordering. Next: IEquatable<T> — why it exists alongside Equals(object), and how it avoids boxing for value types.
dotnetmadeeasy.com — Learn C# and .NET, the right way.