Return Values
By the end of this lesson
Return single and multiple values, and decide when a return type should be nullable.
What a method hands back is the first thing a caller has to understand, and the return type is where you say it.
Three questions decide the shape: is it one value or several, can there be nothing to return, and how should failure reach the caller.
Console.WriteLine(CalculateLateFee(1240.50m, 45)); // 24.81
static decimal CalculateLateFee(decimal invoiceTotal, int daysOverdue)
{
if (invoiceTotal < 10m) return 0m;
if (daysOverdue <= 30) return 0m;
decimal rate = daysOverdue > 60 ? 0.05m : 0.02m;
return Math.Round(invoiceTotal * rate, 2, MidpointRounding.AwayFromZero);
}- return ends the method there and then. Nothing after it in that branch runs.
- Several returns are not untidy when each one states a rule. The alternative — one variable assigned across four branches — is longer and no clearer.
- Every path has to return a value of the declared type, and the compiler checks it. A path with no return is a build error rather than a silent zero.
Returning more than one value
var summary = SummariseOrders(new List<decimal> { 240m, 180m, 620m });
Console.WriteLine($"{summary.Count} orders, {summary.Total:N2} total, largest {summary.Largest:N2}");
static (int Count, decimal Total, decimal Largest) SummariseOrders(List<decimal> orderTotals)
{
decimal total = 0m;
decimal largest = 0m;
foreach (decimal orderTotal in orderTotals)
{
total += orderTotal;
if (orderTotal > largest)
{
largest = orderTotal;
}
}
return (orderTotals.Count, total, largest);
}- The return type is a tuple: several values in brackets, each with a name.
- The names are the point. Without them a caller writes summary.Item1 and summary.Item2, and nobody reading it can tell what those are.
- A tuple suits two or three values that only ever travel together. Once the group has meaning elsewhere in the system, or grows past three parts, give it a real type — a class or a record — so the name can be reused.
When there might be nothing to return
List<Employee> employees = new List<Employee>
{
new Employee("P-2041", "Priya Sharma"),
new Employee("P-2042", "Daniel Okafor"),
};
Employee? found = FindByPayrollNumber(employees, "P-9999");
if (found is null)
{
Console.WriteLine("No employee with that payroll number.");
}
else
{
Console.WriteLine(found.Name);
}
static Employee? FindByPayrollNumber(List<Employee> employees, string payrollNumber)
{
foreach (Employee employee in employees)
{
if (employee.PayrollNumber == payrollNumber)
{
return employee;
}
}
return null;
}
public class Employee
{
public Employee(string payrollNumber, string name)
{
PayrollNumber = payrollNumber;
Name = name;
}
public string PayrollNumber { get; }
public string Name { get; }
}- The question mark in Employee? is a message to the caller: this can be null, so check it.
- With nullable reference types switched on — the default for new projects — the compiler warns anyone who uses the result without checking. Leave the question mark off and it warns inside the method instead, at the return null.
- A method that cannot fail should not return a nullable type. The question mark is information, and adding it as insurance costs every caller a check they do not need.
Four ways to say "there is no answer", and when each fits:
- A nullable return type
- For when finding nothing is an ordinary outcome, such as a search. The caller has to check, and the compiler reminds them.
- Throw an exception
- For when the absence means something is wrong: an order that must exist because you were handed its id a moment ago. Do not use it for a search that legitimately finds nothing.
- The Try pattern
- A bool return plus an out value. Useful when the value is a type that cannot be null, and you want failure handled without an exception.
- An empty collection
- For methods returning many items. Return an empty list rather than null, so every caller can loop without checking first.
Summary
- Every path out of a method must return the declared type — the compiler will not fill in a default
- Named tuples carry two or three related values; anything larger deserves its own type
- A nullable return type tells callers there may be no answer, and the compiler holds them to it
- Return an empty collection rather than null, so callers can loop without a guard
- Choose between nullable, exception and the Try pattern based on whether the absence is routine or wrong
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 method that returns the first overdue invoice from a list, or nothing at all when none is overdue. Handle both outcomes at the call site.
Then decide: would throwing an exception when nothing is overdue ever be reasonable here?
Show solution
A nullable return type fits, because a list with no overdue invoices is a normal state of affairs — most days it is the expected one.
Throwing would be wrong here for the same reason: an exception says something unexpected happened, and this is routine. It would also force every caller into a try block to handle a Tuesday.
The check at the call site is not overhead. It is the method being honest that there may be no answer, in a form the compiler can enforce.
List<(string Reference, int DaysOverdue)> invoices = new List<(string, int)>
{
("INV-2041", 0),
("INV-2042", 12),
};
var overdue = FindFirstOverdue(invoices);
if (overdue is null)
{
Console.WriteLine("Nothing overdue.");
}
else
{
Console.WriteLine($"{overdue.Value.Reference} is {overdue.Value.DaysOverdue} days overdue.");
}
static (string Reference, int DaysOverdue)? FindFirstOverdue(
List<(string Reference, int DaysOverdue)> invoices)
{
foreach (var invoice in invoices)
{
if (invoice.DaysOverdue > 0)
{
return invoice;
}
}
return null;
}Challenge
Challenge
SummariseOrders returns Largest as 0 when the list is empty. Decide whether that is acceptable, and change the method to match your decision.
Defend your answer: what would a report showing a largest order of 0 tell somebody reading it?
Show solution
There is no largest value in an empty set, so 0 is a fabricated answer. It is indistinguishable from a genuine order of zero, and a report saying "largest order: 0.00" reads as a fact rather than as an absence.
Making Largest a decimal? and returning null for an empty list states the truth, and the caller then has to decide how to present it — usually as a dash rather than a number.
The alternative defensible choice is to throw for an empty list, on the grounds that summarising nothing is a caller mistake. That is stricter than most reporting code wants. What is not defensible is returning 0 and hoping.
var summary = SummariseOrders(new List<decimal>());
Console.WriteLine(summary.Largest is null
? $"{summary.Count} orders, no largest to report"
: $"{summary.Count} orders, largest {summary.Largest:N2}");
static (int Count, decimal Total, decimal? Largest) SummariseOrders(List<decimal> orderTotals)
{
if (orderTotals.Count == 0)
{
return (0, 0m, null);
}
decimal total = 0m;
decimal largest = orderTotals[0];
foreach (decimal orderTotal in orderTotals)
{
total += orderTotal;
if (orderTotal > largest)
{
largest = orderTotal;
}
}
return (orderTotals.Count, total, largest);
}Saved in this browser only.