Skip to main content
ANVISoftware Solutions
Lesson 38 of 62Intermediate14 min

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.

Two values that belong together
C#
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 declareNoneOne
Has a nameNoYes, and it appears in tooling and errors
EqualityBy value, element by elementBy value
Room for validationNoneIn an init accessor or a factory method
Adding a third valueChanges the signature for every callerAdd a property with a default
JSON serialisationUsually produces nothing useful, because elements are fieldsSerialises with property names as expected
Best placedPrivate helpers, local grouping, dictionary keysPublic 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.

C#
// 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.