foreach and Deconstruction
These extensions let plain C# syntax work on types that do not support it out of the box. You can write foreach (int i in 0..10), read a file with await foreach (string line in reader), and unpack an array, a GroupBy group, a DateTime or a TimeSpan straight into variables. Reach for them when a loop counter, a ReadLine loop or a row of parts[0], parts[1] lookups hides what the code means.
At a glance
| Method | What it does |
|---|---|
foreach over a Range | foreach (int i in 0..10) yields 0 to 9. The end is exclusive. |
Range.GetEnumerator | Returns the enumerator that foreach uses. You rarely call it yourself. |
RangeEnumerator | The allocation-free struct enumerator returned by Range.GetEnumerator. |
RangeEnumerator.MoveNext | Advances to the next integer. Returns false once the end is reached. |
Step | Walks a range in steps of any size. A negative step counts down. |
AsEnumerable | Turns a range into IEnumerable<int> so any LINQ method can follow. |
Select, Where, SelectMany | Enable LINQ query syntax on ranges: from i in 0..10 where ... select .... |
SelectMany | Pairs each number with an inner range or sequence. Enables a second from clause. |
DateTime.To | foreach (DateTime day in start.To(end)). Both ends included. |
foreach over a TextReader | Reads the remaining lines of any TextReader, lazily. |
TextReader.GetEnumerator | Returns an IEnumerator<string> over the remaining lines, for manual reading. |
await foreach over a TextReader | Reads the remaining lines asynchronously, with optional cancellation. |
IList<T>.Deconstruct | (string user, string domain) = email.Split('@'); for 2 to 5 items. |
IGrouping.Deconstruct | foreach ((string status, IEnumerable<Order> items) in orders.GroupBy(...)). |
DateTime.Deconstruct | Unpacks a date into (year, month, day) or (year, month, day, hour, minute, second). |
TimeSpan.Deconstruct | Unpacks a duration into (hours, minutes, seconds) or (days, hours, minutes, seconds). |
The C# compiler looks up GetEnumerator, GetAsyncEnumerator, Deconstruct, Select, Where and SelectMany by name, and extension methods count. The library declares these methods in the BCL namespaces (System, System.IO, System.Linq, System.Collections.Generic), so they work without an extra using. See Conventions for the general rules.
Ranges in foreach
A C# range such as 0..10 is normally only used for slicing. With this library it is also a sequence of integers. As with slicing, the start is included and the end is excluded.
foreach over a range
foreach over a Range yields every integer from the start up to, but not including, the end. An omitted start means 0, so ..3 is the same as 0..3. The loop uses a struct enumerator (RangeExtensions.RangeEnumerator), so it allocates nothing.
List<string> labels = new List<string>();
foreach (int line in 1..4)
{
labels.Add($"Line {line}");
}
// labels → ["Line 1", "Line 2", "Line 3"]
List<int> firstTwo = new List<int>();
foreach (int i in ..2)
{
firstTwo.Add(i);
}
// firstTwo → [0, 1]
A range whose start is not below its end yields nothing: 5..5 and 5..2 are both empty. To count down, use Step with a negative step.
Both ends must count from the start. A range that uses ^ (0..^1, ^1..) or has an open end (3..) has no fixed length, so enumerating it throws NotSupportedException. The exception is thrown as soon as the loop starts.
The .. operator binds tighter than + and -. Write 1..(pageCount + 1), not 1..pageCount + 1, or the code will not compile.
GetEnumerator, RangeEnumerator and MoveNext
Range.GetEnumerator() is the method the compiler calls for foreach (int i in range). It returns a RangeExtensions.RangeEnumerator, a struct with a Current property and a MoveNext() method. MoveNext() advances to the next integer and returns true while one remains. Current is the integer it moved to.
RangeEnumerator is public only because foreach needs it. You call it by hand only when a loop needs to stop and resume, for example to take a header value before the rest.
RangeExtensions.RangeEnumerator pages = (1..4).GetEnumerator();
bool hasFirst = pages.MoveNext();
// hasFirst → true
int firstPage = pages.Current;
// firstPage → 1
List<int> remainingPages = new List<int>();
while (pages.MoveNext())
{
remainingPages.Add(pages.Current);
}
// remainingPages → [2, 3]
bool hasMore = pages.MoveNext();
// hasMore → false
Notes:
GetEnumeratorthrowsNotSupportedExceptionfor a range with^or an open end, such as(^1..),(0..^0)or(3..).- An empty range (
5..5,5..2) returns an enumerator whose firstMoveNext()isfalse. - Read
Currentonly afterMoveNext()returnedtrue. Before the first call it holds no meaningful value. - It is a mutable
struct. Keep it in a local variable. A copy, such as one passed to a method, advances on its own.
Step
Step(step) walks the range in increments of step. It stops before it reaches or passes the end. A negative step counts down from the start towards the end, so the range is written high-to-low.
IEnumerable<int> batchOffsets = (0..20).Step(5);
// → [0, 5, 10, 15]
IEnumerable<int> reminderDays = (1..10).Step(4);
// → [1, 5, 9]
IEnumerable<int> countdown = (10..0).Step(-3);
// → [10, 7, 4, 1]
Notes:
- A step of
0throwsArgumentOutOfRangeExceptionwith the parameter namestep. - A step that points the wrong way gives an empty sequence:
(10..0).Step(2)and(0..10).Step(-2)both yield nothing. - The end is still exclusive when counting down.
(10..0).Step(-1)yields 10 down to 1, not 0. A C#Indexcannot be negative, so a range cannot end at -1. To include 0, count up and reverse:(0..11).AsEnumerable().Reverse(). - Stepping near
int.MaxValuedoes not overflow:((int.MaxValue - 4)..int.MaxValue).Step(3)yields[int.MaxValue - 4, int.MaxValue - 1]. - A range with
^or an open end throwsNotSupportedExceptionwhenStepis called.
AsEnumerable
AsEnumerable() returns the range as an IEnumerable<int>. Use it when you want an ordinary LINQ method, such as Sum, ToList or OrderByDescending, that a Range does not have.
int total = (1..6).AsEnumerable().Sum();
// → 15
List<int> weekDays = (1..8).AsEnumerable().ToList();
// → [1, 2, 3, 4, 5, 6, 7]
It is the same as Step(1), and it throws NotSupportedException for a range without fixed bounds.
LINQ query syntax on ranges
Select, Where and SelectMany on Range make query syntax work with a range as the source. from, where, select and a second from (over another range or over any sequence) are supported.
IEnumerable<string> ticketCodes = from i in 0..3 select $"#{i}";
// → ["#0", "#1", "#2"]
IEnumerable<int> evenSquares = from i in 0..10 where i % 2 == 0 select i * i;
// → [0, 4, 16, 36, 64]
IEnumerable<(int Row, int Column)> cells = from row in 0..2 from column in 0..3 select (row, column);
// → [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]
IEnumerable<(int X, int Y)> triangle = from x in 0..3 from y in 0..x select (x, y);
// → [(1, 0), (2, 0), (2, 1)]
string[] seatLetters = { "A", "B" };
IEnumerable<string> seats = from row in 1..4 from letter in seatLetters select $"{row}{letter}";
// → ["1A", "1B", "2A", "2B", "3A", "3B"]
IEnumerable<int> diagonal = from x in 0..4 from y in 0..4 where x == y select (x * 10) + y;
// → [0, 11, 22, 33]
The same methods work in method syntax. Wrap the range in parentheses, because . binds tighter than ..:
IEnumerable<int> multiplesOfThree = (1..13).Where(i => i % 3 == 0);
// → [3, 6, 9, 12]
Notes:
- A
nullselector or predicate throwsArgumentNullExceptionwith its parameter name (selector,predicate,innerRangeSelector,collectionSelectororresultSelector). - A source range without fixed bounds throws
NotSupportedExceptionwhen the query is built, not when it is enumerated. - Results are lazy, like normal LINQ.
orderby,group,joinand other clauses need methods thatRangedoes not have. Start those queries from(0..10).AsEnumerable()instead.
SelectMany
SelectMany is what the compiler calls for a second from clause. There are two overloads. One takes innerRangeSelector, a Func<int, Range>, so the inner source is another range. The other takes collectionSelector, a Func<int, IEnumerable<TCollection>>, so the inner source is any sequence. Both take a resultSelector that combines the outer number with each inner item. Query syntax picks the overload for you. Call it directly when you prefer method syntax.
IEnumerable<string> shelfSlots = (1..3).SelectMany(shelf => 0..2, (shelf, slot) => $"S{shelf}-{slot}");
// → ["S1-0", "S1-1", "S2-0", "S2-1"]
string[] sizes = { "S", "M" };
IEnumerable<string> skus = (1..3).SelectMany(model => sizes, (model, size) => $"TSHIRT-{model}-{size}");
// → ["TSHIRT-1-S", "TSHIRT-1-M", "TSHIRT-2-S", "TSHIRT-2-M"]
Notes:
- The inner range can depend on the outer number, as in
from x in 0..3 from y in 0..x. An empty inner range adds nothing for that outer number. - A
nullselector throwsArgumentNullExceptionwith its parameter name (innerRangeSelector,collectionSelectororresultSelector). - An outer range without fixed bounds throws
NotSupportedExceptionwhen the query is built. An inner range without fixed bounds throws when enumeration reaches it.
Dates in foreach
DateTime.To(end) walks from one date to another a day at a time, and To(end, step) uses any positive step. Unlike integer ranges, both ends are included. The full details are on the Dates page.
DateTime checkIn = new DateTime(2024, 5, 1);
DateTime checkOut = new DateTime(2024, 5, 3);
List<DateTime> days = checkIn.To(checkOut).ToList();
DateTime opening = new DateTime(2024, 5, 1, 9, 0, 0);
List<DateTime> slots = opening.To(opening.AddHours(2), 30.Minutes()).ToList();
days → 2024-05-01, 2024-05-02, 2024-05-03
slots → 09:00, 09:30, 10:00, 10:30, 11:00
Reading text line by line
Any TextReader (a StreamReader over a file or an HTTP body, a StringReader, Console.In) can be used directly in foreach and await foreach. Lines are read one at a time, so a large file is never loaded into memory at once.
foreach over a TextReader
foreach (string line in reader) yields the remaining lines without their line terminators. Both \n and \r\n endings are handled. Empty lines come back as empty strings, and a final line terminator does not add an extra empty line.
using StreamReader reader = new StreamReader("orders.csv");
decimal total = 0m;
foreach (string line in reader)
{
(string orderId, string amount) = line.Split(',');
total += decimal.Parse(amount, CultureInfo.InvariantCulture);
}
using StringReader reader = new StringReader("first\r\nsecond\r\n");
List<string> lines = new List<string>();
foreach (string line in reader)
{
lines.Add(line);
}
// lines → ["first", "second"]
using StringReader notes = new StringReader("a\n\n\nb");
List<string> noteLines = new List<string>();
foreach (string line in notes)
{
noteLines.Add(line);
}
// noteLines → ["a", "", "", "b"]
Notes:
- The reader is not disposed when the loop ends. Own it with
using, as above. - Reading starts where the reader currently is. If you
breakafter the first line, the rest stays unread and you can still callreader.ReadToEnd(). - A
nullreader throwsArgumentNullExceptionwith the parameter namereader. - An empty reader yields nothing.
TextReader.GetEnumerator
GetEnumerator() on a TextReader is the method foreach calls. It returns an IEnumerator<string> over the remaining lines. Call it yourself when you need to read part of the input line by line and then hand the rest to other code.
using StringReader reader = new StringReader("OrderId,Amount\nA-1,10\nA-2,20");
using IEnumerator<string> lines = reader.GetEnumerator();
lines.MoveNext();
string header = lines.Current;
// header → "OrderId,Amount"
string body = reader.ReadToEnd();
// body → "A-1,10\nA-2,20"
Lines are read only when MoveNext() is called, so the rest of the reader is untouched. Disposing the enumerator does not dispose the reader. A null reader throws ArgumentNullException with the parameter name reader.
await foreach over a TextReader
await foreach (string line in reader) does the same with ReadLineAsync, so no thread is blocked while waiting for I/O. The line rules are the same as for foreach.
using StreamReader reader = new StreamReader("users.txt");
await foreach (string email in reader)
{
await invitationService.InviteAsync(email);
}
using StringReader reader = new StringReader("first\r\nsecond\nthird");
List<string> lines = new List<string>();
await foreach (string line in reader)
{
lines.Add(line);
}
// lines → ["first", "second", "third"]
To cancel, call GetAsyncEnumerator(cancellationToken) yourself. await foreach directly on a reader passes no token, and WithCancellation does not apply because a TextReader is not an IAsyncEnumerable<string>.
await using IAsyncEnumerator<string> lines = reader.GetAsyncEnumerator(cancellationToken);
while (await lines.MoveNextAsync())
{
await importer.ImportAsync(lines.Current, cancellationToken);
}
Notes:
- The token is checked after each line is read and before it is returned, so cancellation stops the loop between lines with
OperationCanceledException. A token that is already cancelled throws on the firstMoveNextAsyncthat reads a line. An empty reader just ends. - The reader is not disposed, and a
nullreader throwsArgumentNullException(reader), as withforeach.
Deconstruction
Deconstruction unpacks one value into several variables: (int year, int month, int day) = date;. It also works in the head of a foreach loop. The examples below use explicit types. var (a, b) = ... works the same way.
Lists and arrays
Deconstruct on IList<T> takes the first 2, 3, 4 or 5 items. It works on arrays and on List<T>, so the result of string.Split can be unpacked directly. Extra items are ignored.
(string user, string domain) = "john@example.com".Split('@');
// → ("john", "example.com")
(string year, string month, string day) = "2024-05-17".Split('-');
// → ("2024", "05", "17")
(int first, int second) = new[] { 1, 2, 3, 4, 5, 6 };
// → (1, 2)
List<string> roles = new List<string> { "admin", "editor", "viewer" };
(string primary, string secondary, string fallback) = roles;
// → ("admin", "editor", "viewer")
It also works in a loop over a sequence of arrays:
string[] settings = { "region=eu-west", "tier=gold" };
Dictionary<string, string> config = new Dictionary<string, string>();
foreach ((string key, string value) in settings.Select(setting => setting.Split('=')))
{
config[key] = value;
}
// config → { ["region"] = "eu-west", ["tier"] = "gold" }
Notes:
- A list with fewer items than variables throws
ArgumentExceptionwith the parameter namesource, for example "Cannot deconstruct a list of 1 items into 2 variables." Check the length first when the input is untrusted:"localhost".Split(':')has only one part. - A
nulllist throwsArgumentNullException. nullitems are returned asnull.- The receiver is
IList<T>. A type that implements onlyIReadOnlyList<T>orIEnumerable<T>is not covered. - Use a discard for parts you do not need:
(string user, _) = email.Split('@');.
Groupings
Deconstruct on IGrouping<TKey, TElement> gives you the key and the items, so the result of GroupBy can be unpacked in the loop head. The items are the group itself, not a copy.
public sealed record Order(string Status, decimal Amount);
Order[] orders =
{
new Order("Open", 10m),
new Order("Shipped", 20m),
new Order("Open", 5m),
};
Dictionary<string, decimal> totals = new Dictionary<string, decimal>();
foreach ((string status, IEnumerable<Order> items) in orders.GroupBy(order => order.Status))
{
totals[status] = items.Sum(order => order.Amount);
}
// totals → { ["Open"] = 15m, ["Shipped"] = 20m }
A null grouping throws ArgumentNullException with the parameter name grouping.
DateTime
A DateTime can be unpacked into its date parts, or into its date and its time to the second.
(int year, int month, int day) = new DateTime(2024, 5, 17);
// → (2024, 5, 17)
string folder = $"invoices/{year}/{month:D2}/{day:D2}";
// → "invoices/2024/05/17"
(int y, int m, int d, int hour, int minute, int second) = new DateTime(2024, 5, 17, 14, 30, 15, 999);
// → (2024, 5, 17, 14, 30, 15)
Notes:
- The three-part form ignores the time of day. The six-part form drops milliseconds and smaller units.
- The values are read as stored.
Kindis not used, and nothing is converted between local time and UTC.
.NET 8 added DateTime.Deconstruct(out int year, out int month, out int day) as an instance method. The compiler prefers an instance method over an extension, so on .NET 8 and later the BCL method runs and this library's three-part version is hidden. The result is the same. The library's version serves apps that use its netstandard2.1 build on older runtimes. The six-part form always comes from this library.
TimeSpan
A TimeSpan can be unpacked in two ways:
- Three parts
(hours, minutes, seconds): whole days are folded into the hours, so the parts describe the whole duration. Use it for elapsed-time labels such as26:30:00. - Four parts
(days, hours, minutes, seconds): the usualTimeSpancomponents.
(int hours, int minutes, int seconds) = new TimeSpan(1, 2, 30, 0);
// → (26, 30, 0)
string label = $"{hours}:{minutes:D2}:{seconds:D2}";
// → "26:30:00"
(int h, int min, int sec) = new TimeSpan(0, 5, 45, 12, 500);
// → (5, 45, 12)
(int days, int dayHours, int dayMinutes, int daySeconds) = new TimeSpan(1, 2, 30, 45, 500);
// → (1, 2, 30, 45)
Notes:
- Milliseconds and smaller units are dropped in both forms.
- A negative duration gives negative parts:
-new TimeSpan(1, 30, 15)deconstructs to(-1, -30, -15), and-new TimeSpan(3, 4, 5, 6)to(-3, -4, -5, -6). CallDuration()first if you need positive parts for display. TimeSpan.Zerogives all zeros.
See also
- Await anything: the same compiler pattern applied to
await, includingawait foreachover aTimeSpan. - Dates:
Toand the otherDateTimehelpers. - Durations:
30.Minutes()and other ways to build and format aTimeSpan. - Lists and collections: the other
IList<T>extensions.