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
| Method | What it does |
|---|---|
IsNullOrEmpty | true 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 |
FindIndex | Index of the first matching item, or -1 |
InsertRange | Inserts several items at an index, keeping their order |
AddFirst / AddLast | Inserts at the start / adds at the end |
InsertAfter | Inserts right after an item (or the first match); at the start if not found |
InsertBefore | Inserts right before an item (or the first match); at the end if not found |
ReplaceOne | Replaces the first equal or matching item |
ReplaceWhile | Replaces every matching item |
MoveItem | Moves the first matching item to a target index; returns false if nothing matched |
GetOrAdd | Returns the first matching item, or creates and adds one |
Deconstruct | Unpacks 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.
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:
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.
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:
public int CountLines(List<OrderLine>? lines)
{
if (lines.IsNullOrEmpty())
{
return 0;
}
return lines.Count; // no CS8602 warning here
}
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.
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:
HashSet<string> roleSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Admin" };
bool addedAdmin = roleSet.AddIfNotContains("admin"); // → false
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.
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.
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.
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"]
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.
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"]
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.
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.
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.
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>.
string[] statuses = { "draft", "sent", "paid" };
int paidIndex = statuses.FindIndex(s => s == "paid"); // → 2
int voidIndex = statuses.FindIndex(s => s == "void"); // → -1
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>.
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.
An index below 0 or above Count throws ArgumentOutOfRangeException (parameter index). A null list or null items throws ArgumentNullException.
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.
IList<string> recentSearches = new List<string> { "invoices", "refunds" };
recentSearches.AddFirst("orders");
recentSearches.AddLast("archive");
// recentSearches → ["orders", "invoices", "refunds", "archive"]
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.
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>.
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"]
Neither method throws when the anchor is missing. InsertAfter then inserts at the start, and InsertBefore adds at the end:
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.
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)findsitemby equality (IList<T>.IndexOf).ReplaceOne(selector, item)replaces the first item matchingselectorwithitem.ReplaceOne(selector, itemFactory)replaces the first match withitemFactory(currentItem).
IList<string> statuses = new List<string> { "draft", "sent", "sent" };
statuses.ReplaceOne("sent", "paid");
// statuses → ["draft", "paid", "sent"]
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.
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).
List<decimal> prices = new List<decimal> { 9.99m, 0m, 15m, 0m };
prices.ReplaceWhile(p => p == 0m, 1m);
// prices → [9.99, 1, 15, 1]
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"]
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].
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.
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.
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.
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:
List<int> slots = new List<int> { 0, 1 };
int slot = slots.GetOrAdd(x => x == 0, () => 99); // → 0
// slots → [0, 1]
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.
(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.
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.
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
- Querying Sequences: read-only helpers for any
IEnumerable<T>. - Dictionaries:
GetOrAdd, merging and lookups onIDictionary<TKey, TValue>. - Collection Initializers:
Addextensions forStack<T>,Queue<T>and spreading a sequence into a list. - Foreach and Deconstruction: deconstructing lists, arrays and groupings.