Immutable Collections in C# Functional Programming

This article explores what immutable collections are in C# and explains how they facilitate functional programming principles. You will learn the difference between read-only and immutable collections, how data modification works through persistent data structures, and how these collections enable pure functions, safe concurrency, and predictable application state.

What Are Immutable Collections in C#?

Immutable collections, provided by the System.Collections.Immutable NuGet package and namespace, are collection types that cannot be modified after they are created. Unlike mutable collections like List<T> or Dictionary<TKey, TValue>, any operation that appears to mutate an immutable collection—such as adding, removing, or updating an element—does not change the original collection. Instead, it returns a new instance containing the applied changes.

It is critical to distinguish immutable collections from read-only wrappers like ReadOnlyCollection<T> or the IReadOnlyList<T> interface:

  • Read-only collections provide a view that prevents direct modification through that specific reference, but the underlying collection can still be modified elsewhere in memory.
  • Immutable collections guarantee that the underlying data structure itself can never be altered by any reference.

Under the hood, C# immutable collections use efficient persistent data structures (such as balanced binary trees). When you modify an immutable collection, it uses structural sharing, meaning the new collection shares most of its internal memory nodes with the original one. This makes operations memory-efficient and avoids full array copies.

using System.Collections.Immutable;

ImmutableList<int> original = ImmutableList.Create(1, 2, 3);
ImmutableList<int> updated = original.Add(4);

// 'original' still contains [1, 2, 3]
// 'updated' contains [1, 2, 3, 4]

How Immutable Collections Support Functional Programming

Functional programming prioritizes declarative code, pure functions, and immutable data over mutable state and sequential commands. C# includes several functional features (such as lambda expressions, pattern matching, and records), and immutable collections serve as the data backbone for this paradigm.

1. Eliminating Side Effects

In functional programming, functions should not alter the state of passed-in arguments. Passing a standard List<T> to a method introduces the risk of unintentional modification, creating hidden side effects that lead to bugs. By passing immutable collections, developers guarantee that the receiving method cannot alter the caller's state, making the code deterministic and easier to reason about.

2. Enabling Pure Functions and Referential Transparency

A pure function's output depends solely on its input arguments, producing the same result every time without modifying any external environment. Immutable collections allow you to write functions that accept data, apply transformations, and return new data structures without corrupting the inputs. This supports referential transparency, allowing code to be refactored, tested, and reasoned about in isolation.

public static ImmutableList<int> DoubleEvenNumbers(ImmutableList<int> numbers)
{
    return numbers
        .Where(n => n % 2 == 0)
        .Select(n => n * 2)
        .ToImmutableList();
}

3. Inherent Thread Safety

Concurrent programming often suffers from race conditions and deadlocks when multiple threads attempt to read and write to shared state simultaneously. Because immutable collections cannot change after creation, they are inherently thread-safe. Multiple threads can safely read, iterate over, and derive new collections from a shared immutable instance without requiring synchronization locks, semaphores, or defensive copies.

4. Supporting Expression-Based Pipelines

Functional programming relies heavily on chaining operations together into pipelines. Because operations on immutable collections return new collections rather than void, they seamlessly integrate with LINQ and fluent transformation pipelines. You can chain multiple mutations and queries together while keeping intermediate states accessible for history tracking, undo/redo features, or debugging.

Common Immutable Collection Types

C# provides direct immutable counterparts to most standard collections:

  • ImmutableList<T>: Corresponds to List<T>, providing indexed access and ordered storage.
  • ImmutableArray<T>: A thin wrapper around a standard array, offering lower memory overhead and faster read operations than ImmutableList<T> when mutations are rare.
  • ImmutableDictionary<TKey, TValue>: Corresponds to Dictionary<TKey, TValue>, providing key-value associations.
  • ImmutableHashSet<T>: Corresponds to HashSet<T>, providing fast set lookups without duplicates.
  • ImmutableQueue<T> and ImmutableStack<T>: Functional FIFO and LIFO structures that return the modified structure alongside popped or dequeued items.