C# & .NET (C# 14, .NET 10 LTS) Module 8 – Structs, records, tuples and value semantics
Tuples, anonymous types and deconstruction
Return several values from a C# method with named value tuples, take values apart with deconstruction, and use anonymous types in LINQ queries.
What you will learn
- Write a method that returns several values as a named value tuple, and predict what its names do
- Use deconstruction to take tuples, records, dictionary entries and types of your own apart
- Use an anonymous type in a query, and predict its equality and its limits
- Choose between a tuple, an anonymous type and a record for a group of values
- Prevent tuple names from being lost in JSON and in positional assignments
Before you start
What you’ll learn · Before you start
What you will learn
- Write a method that returns several values as a named value tuple, and predict what its names do
- Use deconstruction to take tuples, records, dictionary entries and types of your own apart
- Use an anonymous type in a query, and predict its equality and its limits
- Choose between a tuple, an anonymous type and a record for a group of values
- Prevent tuple names from being lost in JSON and in positional assignments
Before you start
On this page
MinMax(marks) has two answers for the marks 72, 91, 58, 84 and 66: the lowest, 58, and the highest, 91. A method
returns one value, and C# lets that value hold both without a type of your own: (int Min, int Max) is a
value tuple: A tuple such as (int Min, int Max): a System.ValueTuple struct whose elements are public fields named Item1, Item2 and so on. The element names you write exist only for the compiler., and var (lowest, highest) = MinMax(marks) takes it apart again into two
variables. For the rows of a query, new { City, Total } makes an anonymous type: A class without a name that the compiler makes for an expression such as new { City, Total }. Its properties are read-only, Equals compares their values, and it can be used only inside the method that creates it.: a
read-only class that the compiler writes and that stays inside one method.
You will return several values with named tuples, take tuples, records and your own types apart, use anonymous types in queries, and choose which of the three a group of values deserves.
A named value tuple returns several values from one method
minmax.cs returns the lowest and the highest mark as one tuple, then reads it back three ways:
// minmax.cs: two results from one method as a named value tuple, and what the names are.
int[] marks = [72, 91, 58, 84, 66];
var range = MinMax(marks);
Console.WriteLine($"by name: {range.Min} to {range.Max}; by position: {range.Item1} to {range.Item2}");
var (lowest, highest) = MinMax(marks);
Console.WriteLine($"deconstructed: lowest {lowest}, highest {highest}");
Console.WriteLine($"the type when the program runs: {range.GetType()}");
Console.WriteLine($"printed: {range}");
// A value tuple is a mutable struct: its elements are public fields.
range.Min = 0;
Console.WriteLine($"after range.Min = 0: {range}");
// == compares element by element, in order. The names take no part.
var report = (Min: 58, Max: 91);
var flipped = (Max: 58, Min: 91);
Console.WriteLine($"(Min: 58, Max: 91) == (Max: 58, Min: 91): {report == flipped}");
Console.WriteLine($"(Min: 58, Max: 91) == (58, 92): {report == (58, 92)}");
static (int Min, int Max) MinMax(int[] values)
{
int min = values[0], max = values[0];
foreach (int v in values)
{
if (v < min) min = v;
if (v > max) max = v;
}
return (min, max);
} Output
by name: 58 to 91; by position: 58 to 91 deconstructed: lowest 58, highest 91 the type when the program runs: System.ValueTuple`2[System.Int32,System.Int32] printed: (58, 91) after range.Min = 0: (0, 91) (Min: 58, Max: 91) == (Max: 58, Min: 91): True (Min: 58, Max: 91) == (58, 92): False
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run minmax.cs
range.Min and range.Item1 read the same element. The names Min and Max are for the compiler: when the program
ran, the type was System.ValueTuple<int, int>, whose fields are Item1 and Item2. A tuple is a mutable struct, so
range.Min = 0 changed the element. ==, which C# 7.3 added for tuples, compares element by element, in order, and
never reads the names: (Min: 58, Max: 91) == (Max: 58, Min: 91) was True, although the names say the opposite.
| You write | What runs |
|---|---|
(int Min, int Max) |
ValueTuple<int, int>, a struct |
range.Min |
the field Item1 |
(Min: 58, Max: 91) == (Max: 58, Min: 91) |
58 == 58 && 91 == 91, so True |
The names do not survive outside the compiler: System.Text.Json writes {} for a tuple, and an assignment between
tuples matches elements by position, not by name.
Checkpoint
Check: Named value tuples and their names
Deconstruction takes a value apart into variables
deconstruction: Taking a value apart into variables in one statement, as var (min, max) = range does. A tuple gives its elements in order; any other type needs a Deconstruct method with one out parameter per variable. works on more than tuples. deconstruct.cs takes apart a Money
struct, the entries of a dictionary, a DateOnly, a tuple with a value it does not need, and two variables it swaps:
// deconstruct.cs: taking values apart: a type of your own, a dictionary entry, a DateOnly, a swap and discards.
var price = new Money(1_250_50);
var (rupees, paise) = price;
Console.WriteLine($"Money {price.Paise} paise -> {rupees} rupees and {paise} paise");
var pins = new Dictionary<string, string> { ["Pune"] = "411001", ["Nagpur"] = "440001" };
foreach (var (city, pin) in pins)
{
Console.WriteLine($"{city}: {pin}");
}
var (year, month, day) = new DateOnly(2027, 1, 26);
Console.WriteLine($"DateOnly -> year {year}, month {month}, day {day}");
var (_, highest) = MinMax([72, 91, 58]);
Console.WriteLine($"only the highest: {highest}");
int first = 1, second = 2;
(first, second) = (second, first);
Console.WriteLine($"swapped: first {first}, second {second}");
// C# 10: one deconstruction may assign an existing variable and declare a new one.
string station;
(station, var platform) = ("Pune Junction", 3);
Console.WriteLine($"{station}, platform {platform}");
static (int Min, int Max) MinMax(int[] values) => (values.Min(), values.Max());
readonly struct Money(long paise)
{
public long Paise { get; } = paise;
// Called by var (rupees, paise) = price: one out parameter for each variable.
public void Deconstruct(out long rupees, out int paise)
{
rupees = Paise / 100;
paise = (int)(Paise % 100);
}
} Output
Money 125050 paise -> 1250 rupees and 50 paise Pune: 411001 Nagpur: 440001 DateOnly -> year 2027, month 1, day 26 only the highest: 91 swapped: first 2, second 1 Pune Junction, platform 3
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run deconstruct.cs
var (rupees, paise) = price gave 1250 rupees and 50 paise, because Money declares a Deconstruct method with two
out parameters. Follow how the compiler decides what a deconstruction calls:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Money has two parts. var (rupees, paise) = price asks for two variables. price is a Money struct holding 125050 paise.
- Not a tuple. Money is a struct of its own, not a tuple, so the compiler looks for a method named Deconstruct with two out parameters.
- Deconstruct is found. Money declares Deconstruct(out long rupees, out int paise). The compiler calls it, and the method splits 125050 into 1250 and 50.
- Two variables. rupees is 1250 and paise is 50, as the first line of deconstruct.cs printed. Their types come from the out parameters: long and int.
- A tuple goes straight. var (_, highest) = MinMax(...) deconstructs a tuple: its elements are assigned in order, and the discard _ drops the first. No method is looked for.
- A dictionary entry. foreach (var (city, pin) in pins) deconstructs each KeyValuePair<string, string>. KeyValuePair declares a Deconstruct with two out parameters, so each pass gets a city and its PIN.
DateOnly has had a Deconstruct of its own since .NET 8, and KeyValuePair has one too, which is what
foreach (var (city, pin) in pins) calls. A discard, _, fills a position you do not need.
(first, second) = (second, first) swapped two variables through a tuple, and since C# 10 one deconstruction may
assign an existing variable and declare a new one. A type you do not own can get a Deconstruct as an extension
method, and any Deconstruct also lets a switch match a value by its parts.
Checkpoint
Check: Deconstruction into variables
Anonymous types name a query’s results inside one method
An anonymous type is a class the compiler writes for an expression such as
new { City = g.Key, Orders = g.Count(), Total = … }. anonymous.cs groups three orders by city:
// anonymous.cs: anonymous types from a query: read-only, equal by Equals, but == compares references.
Order[] orders = [new("Asha", "Pune", 1200m), new("Ravi", "Nagpur", 450m), new("Meena", "Pune", 300m)];
var byCity = orders
.GroupBy(o => o.City)
.Select(g => new { City = g.Key, Orders = g.Count(), Total = g.Sum(o => o.Amount) });
foreach (var row in byCity)
{
Console.WriteLine(row);
}
var a = new { City = "Pune", Orders = 2 };
var b = new { City = "Pune", Orders = 2 };
Console.WriteLine($"a.Equals(b) {a.Equals(b)}, a == b {a == b}, same type {a.GetType() == b.GetType()}");
Console.WriteLine($"a class? {!a.GetType().IsValueType}; its name: {a.GetType().Name}");
var c = a with { Orders = 3 };
Console.WriteLine($"with {{ Orders = 3 }}: {c}, a is still {a}");
record Order(string Customer, string City, decimal Amount); Output
{ City = Pune, Orders = 2, Total = 1500 }
{ City = Nagpur, Orders = 1, Total = 450 }
a.Equals(b) True, a == b False, same type True
a class? True; its name: <>f__AnonymousType1`2
with { Orders = 3 }: { City = Pune, Orders = 3 }, a is still { City = Pune, Orders = 2 }
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run anonymous.cs
Each row printed its property names and values. Two anonymous objects with the same properties were of one type and
equal by Equals, yet a == b was False: an anonymous type writes Equals and GetHashCode but no ==, so ==
compares references. Its properties are read-only, with copies it since C# 10, and because the type has no name, no
parameter, field or return value can have it: it stays inside the method that made it.
| Feature | Value tuple | Anonymous type | Record |
|---|---|---|---|
| Kind | struct | class | class or struct |
| Names when the program runs | no: Item1, Item2 |
yes, real properties | yes |
== |
compares elements | compares references | compares values |
| Can change | yes, public fields | no | init only, or settable in a record struct |
| Use it outside its method | yes | no | yes |
For two or three values from a private method, a tuple is enough; for a query’s rows inside one method, an anonymous
type or a tuple; for anything that crosses an API or is stored, a record. When the rows are built inside an
Code kept as data: a tree of objects that describes an expression, such as a lambda, so that a library can read it and translate it, for example into SQL, instead of running it.,
as in a database library’s IQueryable queries, take the anonymous type: Microsoft’s comparison of the two notes that
tuples cannot be used in expression trees.
Checkpoint
Check: Anonymous types, and choosing among the three
Tuple names at run time: JSON, reflection, swapped counts and System.Tuple, measured Pro: opens with a Pro, Premium or Ultimate pass
A tuple’s names live in the compiler, so every place where the program meets the outside, JSON, reflection, another method’s signature, loses or ignores them. The runs below show each case and the type that keeps the names.
In this part:
- JSON writes a tuple’s fields, not its names
- Where the names really live: an attribute on the method
- Names do not travel with a value: two counts swapped
- A tuple as a dictionary key, and the comparer each element brings
- System.Tuple, the class from before C# 7
6 runnable examples (C#, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Deconstruction in depth: types you do not own, overloads and positional patterns Pro: opens with a Pro, Premium or Ultimate pass
Deconstruct is an ordinary method that the compiler calls by its name and its number of out parameters. That one
rule lets you add it to types you do not own, offer several shapes of one type, and match values by their parts.
In this part:
- A Deconstruct for a type you do not own
- Overloads by count: two shapes of one passenger
- Positional patterns: a switch that reads Deconstruct
- Deconstruction in loops: Zip, Index and CountBy
- When a tuple should become a record
2 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: statistics as one named tuple
stats.cs checks Stats, which returns the count, the sum, the mean and the median of some fares as one named
tuple, and Summary, which deconstructs it into a line of text. Both start out throwing, so all 7 checks fail:
// stats.cs: seven checks of Stats, which returns four results as one named tuple, and of Summary, which uses them.
using System.Globalization;
decimal[] odd = [400m, 250m, 600m];
decimal[] even = [400m, 250m, 600m, 100m];
var passed = 0;
Check("three fares: count 3, sum 1250, median 400", () => Stats(odd) is (3, 1250m, _, 400m));
Check("three fares: mean 416.67 when rounded", () => Math.Round(Stats(odd).Mean, 2) == 416.67m);
Check("four fares: median 325, the mean of 250 and 400", () => Stats(even).Median == 325m && Stats(even).Mean == 337.5m);
Check("one fare is its own median", () => Stats([99m]).Median == 99m);
Check("leaves the caller's array in its order", () => { Stats(even); return even.SequenceEqual([400m, 250m, 600m, 100m]); });
Check("refuses no fares with ArgumentException", () => { try { Stats([]); return false; } catch (ArgumentException) { return true; } });
Check("Summary of four fares", () => Summary(even) == "4 fares, total 1350.00, mean 337.50, median 325.00");
Console.WriteLine($"{passed} of 7 checks pass");
return passed == 7 ? 0 : 1;
void Check(string name, Func<bool> test)
{
bool ok;
try { ok = test(); }
catch (Exception e) { Console.WriteLine($"FAIL {name}: {e.GetType().Name}"); return; }
Console.WriteLine($"{(ok ? "pass" : "FAIL")} {name}");
if (ok) passed++;
}
// ---- your code: complete Stats and Summary --------------------------------------------------------------------
static (int Count, decimal Sum, decimal Mean, decimal Median) Stats(IEnumerable<decimal> values) => throw new NotImplementedException();
static string Summary(IEnumerable<decimal> values) => throw new NotImplementedException(); Output (exit status 1)
FAIL three fares: count 3, sum 1250, median 400: NotImplementedException FAIL three fares: mean 416.67 when rounded: NotImplementedException FAIL four fares: median 325, the mean of 250 and 400: NotImplementedException FAIL one fare is its own median: NotImplementedException FAIL leaves the caller's array in its order: NotImplementedException FAIL refuses no fares with ArgumentException: NotImplementedException FAIL Summary of four fares: NotImplementedException 0 of 7 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run stats.cs
Write Stats and Summary
Copy stats.cs. In Stats, sort a copy of the values with values.Order(), throw ArgumentException when it is
empty, and take the middle value, or the mean of the two middle values for an even count. In Summary, write
var (count, sum, mean, median) = Stats(values); and format with CultureInfo.InvariantCulture. Run
dotnet run stats.cs until it prints 7 of 7 checks pass.
Then check yourself on tuples, deconstruction and anonymous types:
Practice that adapts to you
Practise this lesson
9 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between a value tuple and an anonymous type?
A value tuple such as (int Min, int Max) is a struct of public fields, so it is copied on assignment and can change,
and its names exist only for the compiler: when the program runs, the fields are Item1 and Item2. A method can
return it. An anonymous type such as new { City, Total } is a class with read-only properties whose names are real.
It is equal to another by Equals but not by ==, and having no name, it cannot leave the method that made it.
-
Fresher to mid
A refactor changed a minimal API endpoint to return (string Status, int Count) instead of a small class. Clients now receive {} and their dashboards show nothing. Explain what happened, and how you would fix the endpoint without breaking the clients again.
-
Fresher to mid
A method returns (int Late, int OnTime), and a caller writes var (onTime, late) = GetCounts(). The daily report shows 40 late trains and 3 on time; the truth is the reverse. Why did nothing catch it, and how would you make the mistake impossible?
-
Mid to senior
A LINQ query builds new { City, Orders, Total } rows, and a teammate wants another class to use them, by returning object or dynamic from the method. What problems does each choice bring, and what would you do instead?
Key takeaways
- A named value tuple returns several values as one struct; its names exist only for the compiler, and
==compares its elements in order without reading them. - Deconstruction assigns a tuple’s elements by position, or calls a
Deconstructmethod with oneoutparameter per variable, asMoney,KeyValuePairandDateOnlydeclare. - An anonymous type is a read-only class with real property names, equal by
Equalsbut not by==, that cannot leave its method; a record is the type to use once values cross an API.
Terms in this lesson
- anonymous type
- A class without a name that the compiler makes for an expression such as new { City, Total }. Its properties are read-only, Equals compares their values, and it can be used only inside the method that creates it.
- deconstruction
- Taking a value apart into variables in one statement, as var (min, max) = range does. A tuple gives its elements in order; any other type needs a Deconstruct method with one out parameter per variable.
- value tuple
- A tuple such as (int Min, int Max): a System.ValueTuple struct whose elements are public fields named Item1, Item2 and so on. The element names you write exist only for the compiler.
References
- Tuple types (C# reference) (Microsoft Learn)
- Tuples and deconstruction (C# fundamentals) (Microsoft Learn)
- Deconstructing tuples and other types (Microsoft Learn)
- Anonymous types (Microsoft Learn)
- Choosing between anonymous and tuple types (Microsoft Learn)
- Include fields in serialization (System.Text.Json) (Microsoft Learn)
- TupleElementNamesAttribute class (Microsoft Learn)
- DateOnly.Deconstruct method (Microsoft Learn)
- The history of C# (tuples and deconstruction in C# 7.0, tuple equality in C# 7.3) (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress