Skip to main content

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​

MethodWhat it does
foreach over a Rangeforeach (int i in 0..10) yields 0 to 9. The end is exclusive.
Range.GetEnumeratorReturns the enumerator that foreach uses. You rarely call it yourself.
RangeEnumeratorThe allocation-free struct enumerator returned by Range.GetEnumerator.
RangeEnumerator.MoveNextAdvances to the next integer. Returns false once the end is reached.
StepWalks a range in steps of any size. A negative step counts down.
AsEnumerableTurns a range into IEnumerable<int> so any LINQ method can follow.
Select, Where, SelectManyEnable LINQ query syntax on ranges: from i in 0..10 where ... select ....
SelectManyPairs each number with an inner range or sequence. Enables a second from clause.
DateTime.Toforeach (DateTime day in start.To(end)). Both ends included.
foreach over a TextReaderReads the remaining lines of any TextReader, lazily.
TextReader.GetEnumeratorReturns an IEnumerator<string> over the remaining lines, for manual reading.
await foreach over a TextReaderReads the remaining lines asynchronously, with optional cancellation.
IList<T>.Deconstruct(string user, string domain) = email.Split('@'); for 2 to 5 items.
IGrouping.Deconstructforeach ((string status, IEnumerable<Order> items) in orders.GroupBy(...)).
DateTime.DeconstructUnpacks a date into (year, month, day) or (year, month, day, hour, minute, second).
TimeSpan.DeconstructUnpacks a duration into (hours, minutes, seconds) or (days, hours, minutes, seconds).
How this works

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.

Numbering invoice lines
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.

Only fixed bounds

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.

Parentheses around arithmetic

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.

Driving the enumerator by hand
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:

  • GetEnumerator throws NotSupportedException for 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 first MoveNext() is false.
  • Read Current only after MoveNext() returned true. 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.

Batches and countdowns
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 0 throws ArgumentOutOfRangeException with the parameter name step.
  • 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# Index cannot be negative, so a range cannot end at -1. To include 0, count up and reverse: (0..11).AsEnumerable().Reverse().
  • Stepping near int.MaxValue does not overflow: ((int.MaxValue - 4)..int.MaxValue).Step(3) yields [int.MaxValue - 4, int.MaxValue - 1].
  • A range with ^ or an open end throws NotSupportedException when Step is 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.

Plain LINQ after a range
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.

Query syntax over ranges
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 ..:

Method syntax
IEnumerable<int> multiplesOfThree = (1..13).Where(i => i % 3 == 0);
// → [3, 6, 9, 12]

Notes:

  • A null selector or predicate throws ArgumentNullException with its parameter name (selector, predicate, innerRangeSelector, collectionSelector or resultSelector).
  • A source range without fixed bounds throws NotSupportedException when the query is built, not when it is enumerated.
  • Results are lazy, like normal LINQ.
  • orderby, group, join and other clauses need methods that Range does 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.

Method syntax for nested loops
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 null selector throws ArgumentNullException with its parameter name (innerRangeSelector, collectionSelector or resultSelector).
  • An outer range without fixed bounds throws NotSupportedException when 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.

Every night of a booking, every slot of a morning
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();
Output
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.

Summing an orders export
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);
}
How lines are split
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 break after the first line, the rest stays unread and you can still call reader.ReadToEnd().
  • A null reader throws ArgumentNullException with the parameter name reader.
  • 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.

Reading a header line, then the body
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.

Inviting users from a file
using StreamReader reader = new StreamReader("users.txt");

await foreach (string email in reader)
{
await invitationService.InviteAsync(email);
}
Mixed line endings
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>.

Cancellable import
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 first MoveNextAsync that reads a line. An empty reader just ends.
  • The reader is not disposed, and a null reader throws ArgumentNullException (reader), as with foreach.

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.

Splitting values into named parts
(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:

Parsing key=value settings
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 ArgumentException with the parameter name source, 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 null list throws ArgumentNullException.
  • null items are returned as null.
  • The receiver is IList<T>. A type that implements only IReadOnlyList<T> or IEnumerable<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.

Totals per order status
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.

Building a folder path from a date
(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. Kind is not used, and nothing is converted between local time and UTC.
The three-part form on .NET 8 and later

.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 as 26:30:00.
  • Four parts (days, hours, minutes, seconds): the usual TimeSpan components.
Showing how long a job ran
(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). Call Duration() first if you need positive parts for display.
  • TimeSpan.Zero gives all zeros.

See also​

  • Await anything: the same compiler pattern applied to await, including await foreach over a TimeSpan.
  • Dates: To and the other DateTime helpers.
  • Durations: 30.Minutes() and other ways to build and format a TimeSpan.
  • Lists and collections: the other IList<T> extensions.