Tuples
By the end of this lesson
Return several related values without declaring a type for them.
Sometimes a method has two answers. Split a gross amount into net and tax, and neither number is useful without the other. The options used to be unappealing: return one value and an out parameter for the other, declare a class that exists for one method, or return an array and lose the names.
A tuple is a lightweight bundle of values, written inline, with names you choose. No type declaration, no file, no ceremony. That convenience is also the reason to be careful with it, which is the second half of this lesson.
public class InvoiceCalculator
{
private const decimal TaxRate = 0.18m;
// A private helper. The two numbers are meaningless apart.
private static (decimal Net, decimal Tax) SplitTax(decimal grossAmount)
{
decimal net = Math.Round(grossAmount / (1m + TaxRate), 2);
return (net, grossAmount - net);
}
public string Describe(decimal grossAmount)
{
// Deconstruction: pull both values into their own variables.
(decimal net, decimal tax) = SplitTax(grossAmount);
return $"Net {net:N2} plus tax {tax:N2}";
}
public decimal TaxOnly(decimal grossAmount)
{
// Take what you need, discard the rest with _.
(_, decimal tax) = SplitTax(grossAmount);
return tax;
}
public decimal NetOnly(decimal grossAmount)
{
// Or keep the tuple and read an element by name.
return SplitTax(grossAmount).Net;
}
}- (decimal Net, decimal Tax) is the return type. The names are for readers and for the compiler; they are not a new type.
- return (net, grossAmount - net); builds the tuple positionally. The first value lands in Net because it is written first.
- The line in Describe is deconstruction: one statement declaring two variables from one tuple. Names on the left are yours to choose and need not match the element names.
- _ is the discard. It tells both the compiler and the next reader that you deliberately ignored that value.
- You can also skip deconstruction and read .Net or .Tax directly, which reads better when you want only one of them.
The same two values, two ways:
| (decimal Net, decimal Tax) | record TaxSplit(decimal Net, decimal Tax) | |
|---|---|---|
| Lines to declare | None | One |
| Has a name | No | Yes, and it appears in tooling and errors |
| Equality | By value, element by element | By value |
| Room for validation | None | In an init accessor or a factory method |
| Adding a third value | Changes the signature for every caller | Add a property with a default |
| JSON serialisation | Usually produces nothing useful, because elements are fields | Serialises with property names as expected |
| Best placed | Private helpers, local grouping, dictionary keys | Public APIs, anything that travels |
Summary
- A tuple bundles several values with chosen names and no type declaration
- Deconstruction unpacks a tuple into variables, and _ discards the elements you do not want
- Element names are a compile-time convenience; the runtime type is ValueTuple, so position is what actually binds
- A tuple suits a private return; once the result crosses a public boundary, a record earns its line
- System.Tuple with Item1 and Item2 is the older form — read it in existing code, do not write it
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Write a private method over a List<Invoice> that returns how many invoices are overdue and the total amount they come to, as a tuple with named elements. Use a foreach loop rather than any library helpers.
Now imagine a reporting page in another project needs that result. Rewrite the method to be public and decide what it should return.
Show solution
The private version is a good use of a tuple. Two values, produced in one place, consumed a few lines later, and a type called OverdueCount would add nothing.
The public version should return a record. Once the caller lives somewhere else, everything the tuple cannot carry starts to matter: a name that appears in tooling, a place to document that Total excludes tax, predictable JSON, and the ability to add AverageDaysLate next quarter without breaking anyone.
There is one more reason worth noticing. Both elements are numbers, so a caller in another project cannot tell from a signature of (int, decimal) which order to read them in — they have to trust the element names, which no compiler check enforces. A record with two named properties removes that risk entirely.
// Private: a tuple is proportionate.
private static (int Count, decimal Total) SummariseOverdue(List<Invoice> invoices, DateOnly today)
{
int count = 0;
decimal total = 0m;
foreach (Invoice invoice in invoices)
{
if (invoice.DueDate < today && invoice.AmountPaid < invoice.Total)
{
count++;
total += invoice.Total - invoice.AmountPaid;
}
}
return (count, total);
}
// Public: give the result a name.
public record OverdueSummary(int Count, decimal OutstandingTotal);
public static OverdueSummary GetOverdueSummary(List<Invoice> invoices, DateOnly today)
{
(int count, decimal total) = SummariseOverdue(invoices, today);
return new OverdueSummary(count, total);
}Think about it
Think about it
A method returns (decimal Net, decimal Tax). A colleague writes (decimal tax, decimal net) = SplitTax(amount); and the totals on an invoice come out wrong.
Why did neither the compiler nor the element names catch this, and what change to the code would have caught it?
Show solution
Deconstruction is positional. The names on the left are new local variables, not references to the element names, so the compiler matched the first element to the first variable and its job was done. Both elements are decimal, so there was no type mismatch to notice.
The element names help a reader who looks at the method signature. They are not a constraint, and the compiler does not compare them with the names you chose on the left.
A record fixes it, because you would write summary.Net and summary.Tax by name and could not get the order wrong. So does a type that makes the two values genuinely different, such as a Money type carrying a role. And so does not deconstructing at all: SplitTax(amount).Tax cannot be misread.
The wider point: when two values of the same type sit next to each other, position is a weak way to tell them apart. That is true of tuples, and equally true of a method taking two decimal parameters.
Saved in this browser only.