Tuples, Deconstruction and Anonymous Types
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Sometimes you want to group a few values without declaring a type. C# offers tuples for that, deconstruction to take them apart again, and anonymous types for ad-hoc projections. All three are deliberately lightweight — and all three have a point past which a named type is the better answer.
Tuples
A tuple literal creates a System.ValueTuple<…> — a mutable struct with public fields:
// Unnamed: elements are Item1, Item2, ...
(int, string) unnamed = (1, "one");
Console.WriteLine($"{unnamed.Item1} {unnamed.Item2}");
// Named elements.
(int Id, string Name) named = (1, "Ada");
Console.WriteLine($"{named.Id} {named.Name}");
// Names may come from the literal, and `var` picks them up.
var inferred = (Id: 2, Name: "Alan");
Console.WriteLine($"{inferred.Id} {inferred.Name}");
// Element names are inferred from variables and members too (C# 7.1).
int id = 3;
string name = "Grace";
var projected = (id, name);
Console.WriteLine($"{projected.id} {projected.name}");
// Tuples nest.
var nested = (Point: (X: 1, Y: 2), Label: "origin-ish");
Console.WriteLine($"{nested.Point.X} {nested.Label}");
// Item1/Item2 always work, whatever the names.
Console.WriteLine(named.Item1);
Element names are a compile-time convenience — they are erased into the field names Item1, Item2, … in IL
(a [TupleElementNames] attribute records them for tooling), which is why two tuples with the same types but
different names are the same type.
Returning Several Values
This is what tuples are best at:
public static class TupleReturns
{
public static (int Quotient, int Remainder) Divide(int dividend, int divisor)
=> (dividend / divisor, dividend % divisor);
public static (bool Success, string? Error) Validate(string input)
=> string.IsNullOrWhiteSpace(input) ? (false, "empty") : (true, null);
// A tuple of a tuple of statistics.
public static (double Min, double Max, double Mean) Summarise(IReadOnlyCollection<double> values)
=> (values.Min(), values.Max(), values.Average());
public static void Demo()
{
var (quotient, remainder) = Divide(17, 5);
Console.WriteLine($"{quotient} r {remainder}"); // 3 r 2
// Or keep the tuple and use the names.
var result = Validate("");
Console.WriteLine($"{result.Success} {result.Error}");
var (min, max, mean) = Summarise([1, 2, 3, 4]);
Console.WriteLine($"{min} {max} {mean}");
}
}
Compare with the alternatives: out parameters do not compose with LINQ or async; a dedicated class is more
ceremony than a two-value return deserves. Tuples fit in between — for internal and private APIs. For a
public API, prefer a readonly record struct, which gives the values a type name, documentation and equality.
Tuple Equality and Assignment
public static class TupleEquality
{
public static void Run()
{
var a = (1, "one");
var b = (1, "one");
Console.WriteLine(a == b); // True -- element-wise (C# 7.3)
Console.WriteLine(a != b); // False
Console.WriteLine(a.Equals(b)); // True
// Names are ignored by ==; only types and values matter.
var named = (Id: 1, Label: "one");
Console.WriteLine(a == named); // True
// Conversions apply element-wise.
(long, string) widened = a;
Console.WriteLine(widened == (1L, "one"));
// Tuple assignment makes multi-assignment and swapping trivial.
int x = 1, y = 2;
(x, y) = (y, x);
Console.WriteLine($"{x} {y}"); // 2 1
// The right side is fully evaluated before any assignment, so this works:
(int previous, int current) = (0, 1);
for (int i = 0; i < 5; i++)
{
(previous, current) = (current, previous + current);
}
Console.WriteLine(previous); // 5
}
}
ValueTuple is a mutable struct with public fields — unusual for the BCL, and a reason not to expose one as
a property or store one in a dictionary you intend to mutate in place.
Deconstruction
Deconstruction splits a value into parts. It works on tuples natively, on records automatically, and on any
type with a Deconstruct method.
public sealed class Rectangle2
{
public Rectangle2(double width, double height) => (Width, Height) = (width, height);
public double Width { get; }
public double Height { get; }
public void Deconstruct(out double width, out double height)
=> (width, height) = (Width, Height);
}
// A Deconstruct extension method works on types you do not own.
public static class DictionaryExtensions
{
public static void Deconstruct<TKey, TValue>(
this KeyValuePair<TKey, TValue> pair, out TKey key, out TValue value)
=> (key, value) = (pair.Key, pair.Value);
}
public record Person3(string First, string Last);
public static class DeconstructionDemo
{
public static void Run()
{
// Explicit types
(double w, double h) = new Rectangle2(3, 4);
// Inferred with one `var`
var (first, last) = new Person3("Ada", "Lovelace");
// Into existing variables
double width, height;
(width, height) = new Rectangle2(5, 6);
// In a foreach
var ages = new Dictionary<string, int> { ["Ada"] = 36 };
foreach ((string personName, int age) in ages)
{
Console.WriteLine($"{personName} {age}");
}
Console.WriteLine($"{w} {h} {first} {last} {width} {height}");
}
}
Rules worth knowing: a Deconstruct method returns void, takes only out parameters, and may be overloaded
as long as the arities differ. Deconstruction is not a conversion — you cannot assign a Rectangle2 to a
tuple variable, only deconstruct it.
Discards
_ throws a value away and allocates nothing:
public static class Discards
{
public static void Run()
{
// Ignore part of a deconstruction.
var (_, last) = new Person3("Ada", "Lovelace");
Console.WriteLine(last);
// Ignore an out parameter.
if (int.TryParse("42", out _))
{
Console.WriteLine("parsed, value not needed");
}
// Ignore a return value deliberately (and silence the analyzer).
_ = Compute();
// In a pattern: "any value, including null".
object value = "x";
string kind = value switch
{
int _ => "int",
string _ => "string",
_ => "other",
};
Console.WriteLine(kind);
}
private static int Compute() => 1;
}
Anonymous Types
An anonymous type is a compiler-generated, immutable, reference type created from a projection:
public static class AnonymousTypes
{
public static void Run()
{
var point = new { X = 1, Y = 2 };
Console.WriteLine(point.X);
Console.WriteLine(point); // { X = 1, Y = 2 } -- generated ToString
// Property names can be inferred from members and variables.
var person = new Person3("Ada", "Lovelace");
var projection = new { person.First, Length = person.Last.Length };
Console.WriteLine($"{projection.First} {projection.Length}");
// Value equality: two instances of the same anonymous type with equal
// properties are equal.
Console.WriteLine(new { X = 1 }.Equals(new { X = 1 })); // True
// `with` works on anonymous types too (C# 10).
var moved = point with { Y = 99 };
Console.WriteLine(moved);
// Where they really earn their place: LINQ projections.
var people = new[] { new Person3("Ada", "Lovelace"), new Person3("Alan", "Turing") };
var summary = people
.Select(p => new { p.First, Initial = p.Last[0] })
.Where(p => p.Initial == 'L')
.ToList();
foreach (var item in summary)
{
Console.WriteLine($"{item.First} {item.Initial}");
}
// Grouping produces anonymous shapes naturally.
var byInitial = people
.GroupBy(p => p.Last[0])
.Select(g => new { Initial = g.Key, Count = g.Count() });
Console.WriteLine(string.Join(", ", byInitial.Select(g => $"{g.Initial}={g.Count}")));
}
}
Limitations that decide when to stop using one: the type has no name, so it cannot be a method’s return type, a field’s type or a generic argument you write out; it is always a reference type (it allocates); and every property is read-only. Once a projection escapes the method it was made in, promote it.
System.Tuple: The Legacy Class
Before C# 7, tuples were the System.Tuple<…> classes:
public static class LegacyTuples
{
public static void Run()
{
Tuple<int, string> old = Tuple.Create(1, "one");
Console.WriteLine($"{old.Item1} {old.Item2}"); // no names, ever
// Reference type: allocates, and equality is via Equals not ==.
Console.WriteLine(old.Equals(Tuple.Create(1, "one"))); // True
Console.WriteLine(old == Tuple.Create(1, "one")); // False -- reference compare!
// Convert to the modern form.
(int Id, string Name) modern = (old.Item1, old.Item2);
Console.WriteLine(modern.Name);
}
}
Use ValueTuple (the (a, b) syntax) in all new code. System.Tuple survives only in older APIs.
When to Promote a Tuple to a Record
Promote when any of these becomes true:
-
The tuple appears in a public API — a caller deserves a documented type name.
-
The same shape is written out in more than two or three places.
-
You want to attach behaviour, validation, or a meaningful
ToString. -
You need it as a dictionary key or in a
HashSet(a tuple works, but a named type documents the intent). -
The element names start carrying real domain meaning.
// Started as:
static (double Latitude, double Longitude) ParseLocation(string text)
=> (0, 0);
// Became:
public readonly record struct GeoLocation(double Latitude, double Longitude)
{
public static GeoLocation Parse(string text)
{
string[] parts = text.Split(',');
return new GeoLocation(
double.Parse(parts[0], CultureInfo.InvariantCulture),
double.Parse(parts[1], CultureInfo.InvariantCulture));
}
public override string ToString() => $"{Latitude:F4},{Longitude:F4}";
}
The conversion is cheap — a readonly record struct with positional parameters has the same
deconstruction, equality and allocation profile as the tuple it replaces, plus a name.
See Also
-
Records — the named alternative, with the same ergonomics.
-
Methods and Parameters —
outparameters andDeconstructmethods. -
Pattern Matching — positional patterns, which use
Deconstruct. -
LINQ — where anonymous types do most of their work.