Tuples
Return two values without declaring a type, name the elements for readability, and know when a record is the better answer.
(int Min, int Max) FindRange(int[] values)A method returns one value. When you need two, a tuple lets you group them without declaring a type first. Write the types in brackets: (int Min, int Max) is a type, and (2, 9) is a value of it.
How it works
The bracket form is shorthand. (int, string) is exactly System.ValueTuple<int, string>, a struct in the base class library whose two public fields are called Item1 and Item2. Tuples are therefore value types, and because those are plain fields rather than properties, a tuple is mutable.
The names you give elements are a compile-time convenience. The compiler rewrites limits.Min to limits.Item1, so the name is gone at run time and reflection sees only Item1. Assignment and comparison go by position and ignore names entirely: (2, 9) == (Low: 2, High: 9) is true. The == and != operators compare element by element and have worked on tuples since C# 7.3.
A tuple also deconstructs, so var (low, high) = FindRange(scores); drops the two elements straight into separate variables.
When to stop using one
Use a tuple for values that travel together inside one method or between two private methods. Once the group crosses a public boundary, declare a record. The names on a tuple carry no guarantees, callers cannot add a method to it, and (string, string, bool, int) tells a reader nothing.
Example
int[] scores = [7, 2, 9, 4];
(int Min, int Max) limits = FindRange(scores);
Console.WriteLine($"min {limits.Min}, max {limits.Max}");
// Deconstruction: the two elements become two separate variables.
var (lowest, highest) = FindRange(scores);
Console.WriteLine($"spread {highest - lowest}");
// The names exist only at compile time. Item1 and Item2 always work.
Console.WriteLine(limits.Item1 == limits.Min);
// Equality compares positions and ignores names.
Console.WriteLine((2, 9) == (Low: 2, High: 9));
static (int Min, int Max) FindRange(int[] values)
{
int min = int.MaxValue;
int max = int.MinValue;
foreach (int value in values)
{
if (value < min)
{
min = value;
}
if (value > max)
{
max = value;
}
}
return (min, max);
}
// Output:
// min 2, max 9
// spread 7
// True
// TrueWhen to use it
- Return a minimum and a maximum from one pass over a collection instead of using two out parameters.
- Group a pair of values as a dictionary value without declaring a type for the pair.
- Swap two variables, or assign several at once, in a single statement.
- Carry two projected fields through a LINQ chain where an anonymous type would not survive the method boundary.
More examples
A tuple as a dictionary value and as an out parameter
Using a tuple as a dictionary value saves declaring a type for a pair that only exists inside this lookup. The deconstruction in the loop header area is the idiomatic way to read it back. Note the Order() call: a Dictionary gives no ordering promise, so printing its keys without sorting makes a test that passes today and fails later.
Dictionary<string, (int Min, int Max)> limits = new()
{
["cpu"] = (1, 8),
["memory"] = (512, 4096)
};
// Dictionary order is not guaranteed, so sort the keys before printing.
foreach (string key in limits.Keys.Order())
{
(int min, int max) = limits[key];
Console.WriteLine($"{key}: {min}-{max}");
}
// A tuple can be the out parameter itself. The names are optional here.
if (limits.TryGetValue("cpu", out (int Min, int Max) cpu))
{
Console.WriteLine(cpu.Max - cpu.Min);
}
// Output:
// cpu: 1-8
// memory: 512-4096
// 7Swapping, and the ValueTuple longhand
The swap works because the right-hand tuple is built before anything is assigned. The second half is the point to internalise: the brackets are pure syntax over ValueTuple, which is why a tuple compares with ==, prints as (404, Not Found), and can be assigned to a differently named tuple of the same shape. You will meet the longhand in older code and in error messages, so recognise it.
int left = 1;
int right = 2;
// Tuple assignment evaluates the whole right side first, so this swaps
// without a temporary variable.
(left, right) = (right, left);
Console.WriteLine($"{left} {right}");
// (int, string) is shorthand for System.ValueTuple<int, string>.
// These two declarations produce the same type.
(int Code, string Text) shorthand = (404, "Not Found");
ValueTuple<int, string> longhand = new ValueTuple<int, string>(404, "Not Found");
Console.WriteLine(shorthand == longhand);
Console.WriteLine(longhand.Item2);
Console.WriteLine(shorthand.ToString());
// Output:
// 2 1
// True
// Not Found
// (404, Not Found)The point at which a record is better
The last two lines show the real cost of a tuple on a public surface: anything working by reflection, including JSON serialization and most object mappers, finds Item1 and Item2 rather than Name and Count. A record carries real properties with real names, prints readably, and can gain methods and validation later. Use a tuple for a local pair; declare a record the moment the shape is returned from a public method or sent over the wire.
// Inside one method, a tuple is enough.
(string Name, int Count) raw = ("widget", 3);
Console.WriteLine($"{raw.Name} {raw.Count}");
// Crossing a boundary, the names have to survive. A record keeps them.
LineItem item = new LineItem("widget", 3);
Console.WriteLine(item);
// Proof that the tuple's names are gone at run time.
Console.WriteLine(raw.GetType().GetProperty("Name") is null);
Console.WriteLine(item.GetType().GetProperty("Name") is null);
record LineItem(string Name, int Count);
// Output:
// widget 3
// LineItem { Name = widget, Count = 3 }
// True
// False
Discussion