Skip to main content

Lists and Collections

These helpers change a collection in place. They work on the interfaces ICollection<T> and IList<T>, so you can use them on List<T>, arrays, Collection<T>, ObservableCollection<T>, HashSet<T> and your own types. Use them when you would otherwise write an IndexOf / Insert / RemoveAt dance by hand. Arrays are fixed-size: on an array, the methods that add, insert, move or remove items throw NotSupportedException, while IsNullOrEmpty, FindIndex, ReplaceOne, ReplaceWhile and Deconstruct work.

At a glance​

MethodWhat it does
IsNullOrEmptytrue when the collection is null or has no items
AddIfNotContains(item)Adds the item only if it is not there yet; returns true if it was added
AddIfNotContains(items)Adds each missing item; returns the items that were added
AddIfNotContains(predicate, itemFactory)Creates and adds an item only if no item matches the predicate
RemoveAll(predicate)Removes every matching item and returns the removed items
RemoveAll(items)Removes each of the given items
FindIndexIndex of the first matching item, or -1
InsertRangeInserts several items at an index, keeping their order
AddFirst / AddLastInserts at the start / adds at the end
InsertAfterInserts right after an item (or the first match); at the start if not found
InsertBeforeInserts right before an item (or the first match); at the end if not found
ReplaceOneReplaces the first equal or matching item
ReplaceWhileReplaces every matching item
MoveItemMoves the first matching item to a target index; returns false if nothing matched
GetOrAddReturns the first matching item, or creates and adds one
DeconstructUnpacks the first 2 to 5 items into variables: (string name, string domain) = email.Split('@');

The ICollection<T> methods live in the static class CollectionExtensions and the IList<T> methods in ListExtensions. Both are in the System.Collections.Generic namespace, so you do not need an extra using.

When List<T> has its own method​

List<T> already has instance methods called InsertRange, FindIndex and RemoveAll. C# always prefers an instance method over an extension method. So on a variable typed as List<T>, those three calls go to the BCL, not to this library.

For InsertRange and FindIndex that makes no difference: the result is the same. For RemoveAll(predicate) it does: List<T>.RemoveAll returns the number of removed items, while the extension returns the removed items.

Which RemoveAll runs
List<OrderLine> cart = new List<OrderLine> { new OrderLine("A-1", 2), new OrderLine("B-2", 0) };
int removedCount = cart.RemoveAll(line => line.Quantity == 0); // → 1 (List<T>.RemoveAll)

ICollection<OrderLine> basket = new List<OrderLine> { new OrderLine("A-1", 2), new OrderLine("B-2", 0) };
IList<OrderLine> removedLines = basket.RemoveAll(line => line.Quantity == 0); // removed items
int removedLineCount = removedLines.Count; // → 1

The extensions are most useful on types that lack these methods: Collection<T>, ObservableCollection<T>, HashSet<T> (RemoveAll only), arrays (FindIndex only), or any variable typed as IList<T> / ICollection<T>.

The examples on this page use these types:

Types used in the examples
public sealed record OrderLine(string Sku, int Quantity);
public sealed record Address(string Name, bool IsDefault);
public sealed record Invoice(string Number, string Status);

public sealed class CartLine
{
public CartLine(string sku)
{
Sku = sku;
}

public string Sku { get; }

public int Quantity { get; set; }
}

Checking and adding​

IsNullOrEmpty​

Returns true when the collection is null or its Count is 0. It works on any ICollection<T>, which includes lists, arrays, sets and dictionaries.

Guarding against missing or empty input
List<string>? tags = null;
bool noTags = tags.IsNullOrEmpty(); // → true

string[] roles = Array.Empty<string>();
bool noRoles = roles.IsNullOrEmpty(); // → true

Dictionary<string, int> stock = new Dictionary<string, int> { ["A-1"] = 4 };
bool noStock = stock.IsNullOrEmpty(); // → false

The parameter is marked [NotNullWhen(false)]. After a false result, the compiler knows the collection is not null, so you get no nullable warning:

Nullable flow analysis
public int CountLines(List<OrderLine>? lines)
{
if (lines.IsNullOrEmpty())
{
return 0;
}

return lines.Count; // no CS8602 warning here
}
note

A null receiver never throws; that is the point of the method. It does not apply to a plain IEnumerable<T> or IReadOnlyCollection<T>, because those types are not ICollection<T>. For lazy sequences see Querying Sequences.

AddIfNotContains(item)​

Adds the item only if Contains says it is not there yet. Returns true if the item was added and false if it was already present. Use it to keep a list free of duplicates without switching to a set.

Granting a role once
List<string> roles = new List<string> { "reader" };

bool added = roles.AddIfNotContains("editor"); // → true
bool addedAgain = roles.AddIfNotContains("editor"); // → false
// roles → ["reader", "editor"]

The check uses the collection's own Contains. A List<T> compares with EqualityComparer<T>.Default (value equality for records and strings, reference equality for classes that do not override Equals). A HashSet<T> uses its comparer, so a case-insensitive set stays case-insensitive:

The collection's comparer decides
HashSet<string> roleSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Admin" };

bool addedAdmin = roleSet.AddIfNotContains("admin"); // → false
note

A null item is allowed and is added if the collection does not already hold null. A null collection throws ArgumentNullException (parameter source).

AddIfNotContains(items)​

Adds each item the collection does not already contain, in order, and returns the items that were actually added. Duplicates inside items are added only once.

Merging subscriber IDs
List<int> subscriberIds = new List<int> { 1, 2 };

IEnumerable<int> newlyAdded = subscriberIds.AddIfNotContains(new[] { 2, 3, 3, 4 }); // → [3, 4]
// subscriberIds → [1, 2, 3, 4]

The items are added immediately, not when you enumerate the result. The returned sequence is a list you can enumerate more than once.

note

source or items being null throws ArgumentNullException with that parameter name.

AddIfNotContains(predicate, itemFactory)​

Adds an item created by itemFactory only if no existing item matches predicate. The factory runs only when the item is added, so it can be expensive. Returns true if an item was added.

Making sure there is one default address
List<Address> addresses = new List<Address> { new Address("Warehouse", IsDefault: false) };

bool created = addresses.AddIfNotContains(a => a.IsDefault, () => new Address("Head office", IsDefault: true)); // → true
bool createdAgain = addresses.AddIfNotContains(a => a.IsDefault, () => new Address("Branch", IsDefault: true)); // → false
// addresses.Select(a => a.Name) → ["Warehouse", "Head office"]
note

Any null argument throws ArgumentNullException (source, predicate or itemFactory).

Removing​

RemoveAll(predicate)​

Removes every item that matches predicate and returns the removed items, in their original order. If nothing matches, it returns an empty list and the collection is unchanged.

Dropping empty cart lines
ICollection<OrderLine> cart = new List<OrderLine>
{
new OrderLine("A-1", 2),
new OrderLine("B-2", 0),
new OrderLine("C-3", 0),
};

IList<OrderLine> removed = cart.RemoveAll(line => line.Quantity == 0);
// removed.Select(l => l.Sku) → ["B-2", "C-3"]
// cart.Select(l => l.Sku) → ["A-1"]
warning

On a variable typed as List<T>, list.RemoveAll(x => ...) calls List<T>.RemoveAll and returns an int. See When List<T> has its own method.

tip

The extension removes each match with ICollection<T>.Remove, which is a linear search on a list. For a large List<T>, the instance List<T>.RemoveAll is faster if you only need the count.

RemoveAll(items)​

Removes each of the given items. Items that are not in the collection are ignored. Each listed item removes one occurrence, as ICollection<T>.Remove does.

Unsubscribing watchers
List<string> watchers = new List<string> { "ana", "ben", "carla", "ben" };

watchers.RemoveAll(new[] { "ben", "dave" });
// watchers → ["ana", "carla", "ben"]

The method copies items before removing, so passing the collection itself is safe: orders.RemoveAll(orders) empties it. List<T> has no instance overload that takes a sequence, so this overload always binds to the extension.

note

source or items being null throws ArgumentNullException. For RemoveAll(predicate), a null predicate throws with parameter name predicate.

Finding and inserting​

FindIndex​

Returns the index of the first item that matches selector, or -1 if no item matches. List<T> has this as an instance method and Array.FindIndex is static; the extension adds it to every IList<T>, so you can call it directly on an array or an ObservableCollection<T>.

Finding a status
string[] statuses = { "draft", "sent", "paid" };

int paidIndex = statuses.FindIndex(s => s == "paid"); // → 2
int voidIndex = statuses.FindIndex(s => s == "void"); // → -1
note

source or selector being null throws ArgumentNullException. The selector is a Predicate<T>.

InsertRange​

Inserts items starting at index and keeps their order. index can be anything from 0 to Count; Count appends to the end. Use it on IList<T> types that have no InsertRange of their own, such as ObservableCollection<T>.

Adding build steps to an observable list
ObservableCollection<string> steps = new ObservableCollection<string> { "restore", "pack" };

steps.InsertRange(1, new[] { "build", "test" });
// steps → ["restore", "build", "test", "pack"]

steps.InsertRange(steps.Count, new[] { "publish" });
// steps → ["restore", "build", "test", "pack", "publish"]

ObservableCollection<T> is in System.Collections.ObjectModel.

warning

An index below 0 or above Count throws ArgumentOutOfRangeException (parameter index). A null list or null items throws ArgumentNullException.

info

Items are inserted one at a time through IList<T>.Insert. On an ObservableCollection<T> this raises one CollectionChanged event per item.

AddFirst / AddLast​

AddFirst inserts the item at index 0. AddLast adds it at the end, the same as Add, and exists so the two read as a pair.

Keeping recent searches
IList<string> recentSearches = new List<string> { "invoices", "refunds" };

recentSearches.AddFirst("orders");
recentSearches.AddLast("archive");
// recentSearches → ["orders", "invoices", "refunds", "archive"]
note

null items are allowed. A null list throws ArgumentNullException. LinkedList<T> has its own AddFirst / AddLast instance methods, which C# uses instead.

InsertAfter​

Inserts item right after existingItem, found by equality (IList<T>.IndexOf). A second overload takes a Predicate<T> and inserts after the first match.

Adding a step to a pipeline
List<string> pipeline = new List<string> { "restore", "build", "pack" };

pipeline.InsertAfter("build", "test");
// pipeline → ["restore", "build", "test", "pack"]

pipeline.InsertAfter(step => step.StartsWith("te"), "coverage");
// pipeline → ["restore", "build", "test", "coverage", "pack"]

InsertBefore​

Inserts item right before existingItem, or before the first item that matches a Predicate<T>.

Adding a clean step first
List<string> pipeline = new List<string> { "restore", "build", "pack" };

pipeline.InsertBefore("restore", "clean");
// pipeline → ["clean", "restore", "build", "pack"]

pipeline.InsertBefore(step => step == "pack", "test");
// pipeline → ["clean", "restore", "build", "test", "pack"]
warning

Neither method throws when the anchor is missing. InsertAfter then inserts at the start, and InsertBefore adds at the end:

Anchor not found
List<string> pipeline = new List<string> { "build", "pack" };

pipeline.InsertAfter("lint", "test");
pipeline.InsertBefore("deploy", "publish");
// pipeline → ["test", "build", "pack", "publish"]

Check with Contains or FindIndex first if a missing anchor is an error in your code.

note

Only the first equal or matching item is used. A null list, or a null selector in the predicate overloads, throws ArgumentNullException. null items are allowed.

Replacing​

ReplaceOne​

Replaces only the first item. There are three overloads:

  • ReplaceOne(item, replaceWith) finds item by equality (IList<T>.IndexOf).
  • ReplaceOne(selector, item) replaces the first item matching selector with item.
  • ReplaceOne(selector, itemFactory) replaces the first match with itemFactory(currentItem).
Marking the first sent invoice as paid
IList<string> statuses = new List<string> { "draft", "sent", "sent" };

statuses.ReplaceOne("sent", "paid");
// statuses → ["draft", "paid", "sent"]
Updating the first matching order line
List<OrderLine> lines = new List<OrderLine>
{
new OrderLine("A-1", 2),
new OrderLine("B-2", 1),
new OrderLine("B-2", 5),
};

lines.ReplaceOne(l => l.Sku == "B-2", l => l with { Quantity = l.Quantity + 1 });
// lines.Select(l => l.Quantity) → [2, 2, 5]

lines.ReplaceOne(l => l.Sku == "A-1", new OrderLine("A-1", 10));
// lines.Select(l => l.Quantity) → [10, 2, 5]

If nothing is equal or matches, the list is left unchanged and nothing is thrown.

note

ReplaceOne(item, replaceWith) uses equality, not ordering, so it works for any T. A null list, selector or factory throws ArgumentNullException.

ReplaceWhile​

Replaces every item that matches selector, either with a fixed item or with itemFactory(currentItem).

Fixing zero prices
List<decimal> prices = new List<decimal> { 9.99m, 0m, 15m, 0m };

prices.ReplaceWhile(p => p == 0m, 1m);
// prices → [9.99, 1, 15, 1]
Sending reminders for overdue invoices
List<Invoice> invoices = new List<Invoice>
{
new Invoice("INV-1001", "overdue"),
new Invoice("INV-1002", "paid"),
new Invoice("INV-1003", "overdue"),
};

invoices.ReplaceWhile(i => i.Status == "overdue", i => i with { Status = "reminded" });
// invoices.Select(i => i.Status) → ["reminded", "paid", "reminded"]
warning

Despite the name, ReplaceWhile does not stop at the first item that does not match. It checks every item in the list. new List<int> { 1, 2, 3 } with ReplaceWhile(x => x != 2, x => x * 10) gives [10, 2, 30].

note

A null list, selector or factory throws ArgumentNullException.

Reordering and lookup​

MoveItem​

Moves the first item that matches selector so that it ends up at targetIndex. Returns true if an item was found (even when it was already at that index) and false if nothing matched. Use it for drag-and-drop ordering or pinning an item to the top.

Reordering mail folders
List<string> folders = new List<string> { "Inbox", "Drafts", "Sent", "Archive" };

bool moved = folders.MoveItem(f => f == "Inbox", 2); // → true
// folders → ["Drafts", "Sent", "Inbox", "Archive"]

bool pinned = folders.MoveItem(f => f == "Archive", 0); // → true
// folders → ["Archive", "Drafts", "Sent", "Inbox"]

bool missing = folders.MoveItem(f => f == "Spam", 0); // → false (list unchanged)

targetIndex is the item's index after the move, counted in the final list.

warning

targetIndex must be from 0 to Count - 1, otherwise ArgumentOutOfRangeException (parameter targetIndex) is thrown. The index is checked before the search, so an out-of-range index throws even when nothing matches, and every call on an empty list throws. A null list or selector throws ArgumentNullException.

GetOrAdd​

Returns the first item that matches selector. If none matches, it calls factory, adds the new item to the end, and returns it. The factory is not called when a match exists.

Adding to a cart line
List<CartLine> cart = new List<CartLine> { new CartLine("A-1") { Quantity = 2 } };

CartLine existing = cart.GetOrAdd(l => l.Sku == "A-1", () => new CartLine("A-1"));
existing.Quantity++;

CartLine created = cart.GetOrAdd(l => l.Sku == "C-3", () => new CartLine("C-3"));
created.Quantity++;
// cart.Select(l => $"{l.Sku}:{l.Quantity}") → ["A-1:3", "C-3:1"]

It works for value types too. A match that equals default (for example 0) still counts as found:

Value types
List<int> slots = new List<int> { 0, 1 };

int slot = slots.GetOrAdd(x => x == 0, () => 99); // → 0
// slots → [0, 1]
note

The selector is a Func<T, bool>. Any null argument throws ArgumentNullException (source, selector or factory). The method is not thread-safe; lock around it if several threads share the list. For dictionaries, see GetOrAdd on the Dictionaries page.

Deconstruction​

Deconstruct​

Deconstruct unpacks the first 2, 3, 4 or 5 items of an IList<T> into separate variables. Arrays and List<T> both implement IList<T>, so you can unpack the result of string.Split in one line. Items after the last variable are ignored.

Unpacking split strings and lists
(string user, string domain) = "john@example.com".Split('@');
// user → "john", domain → "example.com"

(string year, string month, string day) = "2024-05-17".Split('-');
// → ("2024", "05", "17")

List<string> approvers = new List<string> { "alice", "bob", "carol" };
(string first, string second, string third) = approvers;
// → ("alice", "bob", "carol")

(int q1, int q2, int q3, int q4) = new[] { 120, 95, 140, 110 };
// → (120, 95, 140, 110)

(int top, int runnerUp) = new[] { 1, 2, 3, 4, 5, 6 };
// → (1, 2); the other items are ignored

Use a discard for the parts you do not need: (string user, _) = email.Split('@');. The C# compiler picks the overload from the number of variables, so no method name appears in the code.

warning

The list must have at least as many items as there are variables. Otherwise ArgumentException is thrown with the parameter name source and a message such as "Cannot deconstruct a list of 1 items into 2 variables." Check the length first when the input comes from users: "localhost".Split(':') has only one part. A null list throws ArgumentNullException.

note

null items are returned as null: (string? a, string? b) = new List<string?> { null, "b" }; gives a → null, b → "b". The receiver is IList<T>, so a type that implements only IReadOnlyList<T> or IEnumerable<T> cannot be deconstructed. Deconstructing groupings and using it in foreach heads is covered in Foreach and Deconstruction.

See also​