The Essential Interfaces Every C# Developer Should Know

Hello there! πŸ‘‹πŸ»

If you use C# on a daily basis, or you code with it for some side projects, you will properly encounter a bunch of interfaces that seem to always be around, like IEnumerable<T>, ICollection<T>, and IDisposable. These are really common to see in almost every codebase, but there are some more hidden gems that you should also learn about, which is what we will be talking about in this post. We’ll embark on a journey to understand some of C#’s most essential interfaces so that whenever one comes up, you know exactly why that interface would make sense and how it should be used.

Table of Contents

  • IEnumerable & IEnumerator
  • IEquatable
  • IDisposable
  • IComparable
  • IEqualityComparer
  • Wrapping up

The sections of this post are NOT related. Assuming you have some knowledge of C#, feel free to skip and read whichever section you need; there’s no linkage between the sections.

IEnumerable and IEnumerator

These two interfaces cause a lot of confusion for newcomers to .NET, and even experienced developers still don’t understand the purpose of each of them, because we just use them as they are. But let’s break them down.

IEnumerable is a contract that says a collection can be enumerated β€” in other words, you can use it in a foreach loop. It doesn’t perform the iteration itself; it just promises to hand you something that can.
IEnumerator is that something; it’s the object that actually walks through the collection, keeping track of where you are and exposing each element one by one.

It’s quite niche to actually hand-roll an IEnumerable since C# ships with a huge variety of enumerable types that satisfy 99% of your use cases. However, we will still go over an implementation just in case this 1% did in fact hit, such that you are prepared.

using System.Collections;

public class FileLineEnumerable : IEnumerable<string>
{
    private readonly string _path;
    public FileLineEnumerable(string path) => _path = path;

    public IEnumerator<string> GetEnumerator()
    {
        using var reader = new StreamReader(_path);
        string? line;
        while (!string.IsNullOrEmpty(line = reader.ReadLine()))
            yield return line;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

This simple implementation of IEnumerable turns a file into a collection, making it possible to iterate through the lines of the file line by line and stream them back to the caller. Here’s how this would be used:


var errorLogs = new FileLineEnumerable("logs.txt")
    .Where(line => line.Contains("ERROR"));

foreach (var log in errorLogs)
    Console.WriteLine(log);

By implementing IEnumerable, we were able to turn a normal .txt file into a collection that we can iterate through line by line!

NOTE πŸ’‘: What makes IEnumerable even more powerful is Deferred Execution. When dealing with instances of IEnumerable, you are not actually computing results, you’re just building a pipeline that will stay there and ONLY execute when you actually need it to. In the previous code sample where we looked for ERROR logs in the text file, the Where LINQ function returns an IEnumerable<T>, so the filtering didn’t actually happen until we enumerated the IEnumerable, which happens either when you use it in a foreach, or when you call things like ToArray(), ToList() and so on, keep that in mind!

IEquatable

Our next guy, the fellow interface who carries this slogan on its shoulders:

What does it mean for two objects to be equal?
~ IEquatable

In C#, every type implicitly or explicitly inherits from System.Object, which defines the following equality method:

public virtual bool Equals(object? obj);

This one is a bit slow and error-prone, because it’s basically not type-safe, which makes the runtime have to do some work like null checks, validate type correctness (that you are not comparing Person to Address), and it would also have to unbox value types, so a lot of headache.

Enter IEquatable<T>;

public interface IEquatable<T>
{
    bool Equals(T? other);
}

This interface defines a type-safe method for equality checks on two objects of the same type.
Let’s take a look at an implementation of this interface to understand it better, because this one is more practical than theoretical.
Say we have this User class:

public class User
{
    public string Username { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

Say in the system, users can have different usernames, but the same email, like think of the email as the unique ID, if I have these two user objects:

var user1 = new User()
{
    Username = "user-1",
    Email = "user1@mail.com"
};

var user2 = new User()
{
    Username = "user-2",
    Email = "user1@mail.com"
};

Each with a different username, but the same email, if I call Equals(object?) on them, it will hand me back false. That’s because the default implementation of Equals(object?) inherited from System.Object, only checks if the two objects have the same memory reference, it only checks references equality, not properties or values, even if the two user object had the exact same values for both username and email, this would still return false.

Now let’s implement IEquatable<T>:

public class User : IEquatable<User>
{
    public string Username { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;

    public bool Equals(User? other)
    {
        if (other is null)
            return false;

        return string.Equals(Email, other.Email, StringComparison.OrdinalIgnoreCase);
    }

    public override bool Equals(object? obj)
        => Equals(obj as User);

    public override int GetHashCode()
        => StringComparer.OrdinalIgnoreCase.GetHashCode(Email);
}

This is the necessary implementation needed to change the equality comparison. However, you might see that we have overridden two other methods that are not part of the interface, which are Equals(object?) and GetHashCode(). Now implementing the interface alone would make the comparison by email alone work when you in fact call it on an instance of User like doing user.Equals(otherUser), but other constructs like List<T>, and HashSet<T> and those, they rely on the Equals(object?), so if you do list.Contains(user), thinking it would search for it by email, it would return false even if that email was indeed there. Also, if you try using the == operator, it would call the Equals(object?) under the hood, which would do reference equality checks only, as we mentioned before, so overriding these two functions is crucial when implementing the interface to avoid strange behaviors with equality checks, searching, and hash maps.

IDisposable

public interface IDisposable
{
    void Dispose();
}

IDisposable defines a single method Dispose(); this method’s implementation becomes necessary when dealing with unmanaged resources. Before we dive in further, let’s explain Managed and Unmanaged resources.

Managed Resources: Things that are fully controlled by the .NET runtime and garbage collector, these are normal objects, like when you do new User(), basically objects that live on the heap. The garbage collector, or GC, knows how to automatically reclaim the memory of them once they are no longer referenced.

Unmanaged Resources: Things that the GC does not understand, like it doesn’t know how to reclaim their memory, things like file handles, database connections, and other wrappers like FileStream, SqlConnection, these wrappers hold on to unmanaged resources themselves, that’s why it’s needed to call Dispose() on them once you’re done using them, or by putting them inside a using block, which internally calls Dispose on the resource once the block or scope ends.

Let’s look at this implementation of a File Logger, which takes in a message and appends it to a log file:

public class FileLogger : IDisposable
{
    private readonly StreamWriter _writer;
    private bool _disposed;

    public FileLogger(string filePath)
    {
        _writer = new StreamWriter(filePath, append: true);
    }

    public void Log(string message)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileLogger));

        var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        _writer.WriteLine($"[{timestamp}] {message}");
    }

    public void Dispose()
    {
        if (_disposed) return;

        _writer.Dispose();
        _disposed = true;
    }
}

Simple and easy, the StreamWriter holds on internally to unmanaged resorce that it needs to dispose of, so by implementing IDisposable on our file logger, we can call Dispose on the writer once the file logger is no longer needed and mark _disposed as true, this is necessary because Dispose must be ideompotent, meaning its safe to call it multiple times.

There are two ways to call Dispose, let’s look at them both

// METHOD 1
using (var logger = new FileLogger("logs.txt"))
{
    logger.Log("Application started.");
    logger.Log("Processing request...");
}

// METHOD 2
var logger = new FileLogger("logs.txt");
logger.Log("Processing request...");
logger.Dispose();

The first will call Dispose automatically once the scope of the using ends, which is more convenient and reduces the chances of you forgetting to manually call it.

IComparable

Here’s another interface that often gets confused with IEquatable, but they serve very different purposes. While IEquatable answers “are these two objects the same?”, IComparable answers “which one comes first?”

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

The CompareTo method returns an integer that tells us the relative ordering:

  • Negative value: current instance comes before the other
  • Zero: they’re equal in sort order
  • Positive value: current instance comes after the other

Let’s say we’re building a task management system where tasks have priorities and due dates. We want to sort tasks intelligently, first by priority, then by due date if priorities are the same.

public class Task : IComparable<Task>
{
    public string Title { get; set; } = string.Empty;
    public int Priority { get; set; }  // 1 = High, 2 = Medium, 3 = Low
    public DateTime DueDate { get; set; }

    public int CompareTo(Task? other)
    {
        if (other is null)
            return 1;  // null values go to th end

        // First, compare by priority (lower number = higher priority)
        int priorityComparison = Priority.CompareTo(other.Priority);
        if (priorityComparison != 0)
            return priorityComparison;

        // If priorities are equal, compare by due date
        return DueDate.CompareTo(other.DueDate);
    }
}

Now here’s the beautiful part, once we implement IComparable<T>, our objects work seamlessly with sorting operations:

var tasks = new List<Task>
{
    new() { Title = "Deploy to production", Priority = 1, DueDate = DateTime.Now.AddDays(2) },
    new() { Title = "Code review", Priority = 2, DueDate = DateTime.Now.AddDays(1) },
    new() { Title = "Write tests", Priority = 1, DueDate = DateTime.Now.AddDays(1) },
    new() { Title = "Update documentation", Priority = 3, DueDate = DateTime.Now }
};

tasks.Sort();  // Automatically uses our CompareTo implementation!

foreach (var task in tasks)
    Console.WriteLine($"{task.Priority}: {task.Title} - Due: {task.DueDate:MM/dd}");

The tasks will be sorted first by priority (all Priority 1 tasks first), and within each priority level, they’ll be sorted by due date. No need to write custom sorting logic everywhere, implement it once, and simply use it everywhere!

NOTE πŸ’‘: Similar to IEquatable<T>, there’s also a non-generic IComparable interface that uses object as the parameter type. And just like with equality, the generic version is preferred for type safety and performance. Also, if you implement IComparable<T>, it’s good practice to override Equals(object?) and GetHashCode() to maintain consistency; if two objects are “equal” according to CompareTo(object?) (returning 0), they should also be equal according to Equals(object?).

IEqualityComparer

Sometimes you need to compare objects in different ways depending on the context, or you’re working with types you can’t modify (like third-party library classes or built-in types). That’s where IEqualityComparer<T> comes in; it’s an external judge that decides if two objects are equal. Unlike IEquatable<T>, where we are comparing the instance against something else, this one is more on the outside and provides decent flexibility when changing comparison strategies.

public interface IEqualityComparer<T>
{
    bool Equals(T? x, T? y);
    int GetHashCode(T obj);
}

This interface lets you define equality logic that lives outside the objects being compared. Think of it as a referee that can look at two objects and decide if they’re the same based on whatever rules you give it.
Let’s say we’re building an e-commerce system where we need to compare products in different ways, sometimes by SKU (Stock Keeping Units in case you dont know…), sometimes by name, and sometimes by a combination of attributes:

public class Product
{
    public string SKU { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

// Comparer for checking if products are the same SKU
public class ProductSKUComparer : IEqualityComparer<Product>
{
    public bool Equals(Product? x, Product? y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;

        return string.Equals(x.SKU, y.SKU, StringComparison.OrdinalIgnoreCase);
    }

    public int GetHashCode(Product obj)
    {
        return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.SKU);
    }
}

// Comparer for finding similar products (same name and category)
public class ProductSimilarityComparer : IEqualityComparer<Product>
{
    public bool Equals(Product? x, Product? y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;

        return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase) &&
               string.Equals(x.Category, y.Category, StringComparison.OrdinalIgnoreCase);
    }

    public int GetHashCode(Product obj)
    {
        return HashCode.Combine(
            StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name),
            StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Category)
        );
    }
}

This implementation shows how this interface makes it easy to provide different, easily swapable comparison strategies based on whatever we want to achieve. These can also be passed to constructs like HashSet<T>, for example, to provide a way of comparing such that the set would only store instances that pass that comparison strategy to avoid duplication.
Let’s look at how this is used with LINQ:

var inventory = new List<Product>
{
    new() { SKU = "LAPTOP-001", Name = "Gaming Laptop", Category = "Electronics", Price = 1299.99m },
    new() { SKU = "LAPTOP-002", Name = "Gaming Laptop", Category = "Electronics", Price = 1499.99m },
    new() { SKU = "MOUSE-001", Name = "Wireless Mouse", Category = "Accessories", Price = 29.99m },
    new() { SKU = "LAPTOP-001", Name = "Gaming Laptop Pro", Category = "Electronics", Price = 1299.99m }
};

// Find duplicate SKUs in inventory
var duplicateSKUs = inventory
    .GroupBy(p => p, new ProductSKUComparer())
    .Where(g => g.Count() > 1)
    .Select(g => g.Key.SKU);

Console.WriteLine("Duplicate SKUs found:");
foreach (var sku in duplicateSKUs)
    Console.WriteLine($"  - {sku}");

// Find all similar products (same name and category)
var similarProducts = inventory
    .Distinct(new ProductSimilarityComparer())
    .ToList();

Console.WriteLine($"nUnique product types: {similarProducts.Count}");

// Using with HashSet for fast lookups
var processedProducts = new HashSet<Product>(new ProductSKUComparer());
foreach (var product in inventory)
{
    if (!processedProducts.Add(product))
        Console.WriteLine($"Already processed: {product.SKU}");
}

And that’s it!

Wrapping up

I hope you found some value in this post. I intended to make it so that sections are fully independent, so if you’re interested only in one, you can just read that part and get the knowledge you need. If you have questions or feel like this can be expanded to address more, please let me know in the comments section!

Thanks for reading! πŸ‘‹πŸ»

Similar Posts