Last Updated: May 22, 2026
A method's return type is the contract it offers to callers: what shape of value will come back when the method finishes. C# is strict about this contract, the compiler refuses to build a method that promises an int and forgets to return one on some code path. This lesson walks through what can be returned, how return actually behaves, and how tuples let a single method hand back several related values at once without inventing a class for the job.
Any C# type can sit on the left of a method name as its return type. Primitives, strings, arrays, lists, dictionaries, your own classes, structs, enums, delegates, tuples, even other methods (wrapped in a delegate). There's no special category of "returnable" types, the language treats them all the same way.
A handful of common shapes from an e-commerce setting:
Each of those methods declares one return type and hands one value back. The caller gets exactly that type and can use it however that type allows: arithmetic on a decimal, indexing on an array, iteration on a List<T>, and so on.
The methods in that block use => (expression-bodied syntax) because each body is a single expression. That style was introduced in C# 6 and is the standard form for one-line methods. The longer form with braces and an explicit return keyword is covered later in this lesson.
Arrays, lists, dictionaries, and custom classes are reference types, so what actually comes back is a reference, not a copy of the data. That detail matters when the caller mutates the result, every change is visible to anyone else holding the same reference.
void is the one return type that isn't a real type. It's the keyword you use when a method doesn't have anything meaningful to give back. Its job is the side effect: printing, modifying state, sending a network request.
Two rules apply inside a void method:
return; (bare, no value) to exit early.return someValue;, because there's no return type to match.That bare return is useful for guard clauses:
The return; inside the if block stops execution at that point. Anything after the guard is skipped when the email is missing. This is the same return keyword used by value-returning methods, just without an expression after it.
This lesson assumes you've already met void in the Methods Basics chapter. Everything that follows focuses on methods that do return a value.
return does two things at the same time:
return in the same execution path runs.A minimal value-returning method:
The expression after return can be anything that evaluates to the declared type. A literal, a variable, a method call, an arithmetic expression, a conditional expression, all valid:
The return type and the value handed to return have to be compatible. Hand back something the compiler can't convert to the declared type and the build fails:
What's wrong with this code?
The method promises a decimal, but the literal "89.97" is a string. The compiler reports error CS0029 ("Cannot implicitly convert type 'string' to 'decimal'") and refuses to build. The fix is either to return the right type (return 89.97m;) or to parse the string explicitly with decimal.Parse("89.97") if the value really has to start as a string.
The compatibility check uses implicit conversions. Returning an int from a method declared decimal works because there's an implicit int to decimal conversion. Returning a double from a decimal-returning method doesn't, because double to decimal requires an explicit cast.
Most non-trivial methods have more than one place where return shows up. A method that classifies orders, validates input, or short-circuits on edge cases needs several exit points.
The compiler enforces one rule across all of them: every reachable code path has to return a value of the declared type. If even one path falls off the end without returning, the build fails.
A discount calculator with a few branches:
Each if block has its own return for the matching tier. The final return 0m; outside all the if blocks is the fallback for carts below $50. Drop that last line and the compiler complains:
What's wrong with this code?
CS0161 ("not all code paths return a value"). When the cart total is below $50, none of the three if conditions match, and the method runs to the closing brace without ever hitting a return. The fix is the same as before: add a final return 0m; for the missing case. Some teams prefer to write that fallback as the body of an else, others as a bare statement at the end. Both compile.
The compiler is conservative here. It doesn't try to prove that one of the branches is guaranteed to fire, it just checks the control-flow graph. Even when you, the human, can see that every input must hit one of the branches, the compiler often can't. Add the explicit fallback and move on.
The classic alternative to a deeply nested if chain is to validate inputs first and bail out early. The "happy path" then lives flush against the left margin, easy to read.
A validation method that fails fast:
Each guard handles one bad case and exits. The method's tail runs only when everything is valid. The alternative, a single nested if with three levels of indentation, is harder to read and harder to extend. The early-return style stays flat as more rules get added.
return inside a loop exits the entire method on the spot, not just the loop. That's useful when you're searching for a match and want to stop as soon as you find it.
The loop runs only until it hits the first item with stock[i] == 0. The return names[i]; ends the method immediately, so iterations beyond Keyboard never run. Falling out of the loop means nothing matched, and the final return null; reports that case.
The return type here is string?, the nullable string. The ? says "this method might return null," which lets the compiler check the caller for missing null handling. Without the ?, the line return null; would warn under nullable reference types (enabled by default in .NET 6+ project templates).
When a method's whole body is a single expression, the curly braces and the return keyword are noise. C# 6 introduced => to let you write that case in one line.
The => form is shorthand. The compiler generates the same IL it would for the equivalent block body, so there's no runtime difference. The only restriction is that the body must be a single expression, you can't put multiple statements, an if/else, or a loop after the =>.
When to pick which form:
| Method body | Recommended form |
|---|---|
| One expression, fits on one line | Expression-bodied (=>) |
| One expression, but reads better split across lines | Expression-bodied with line break before => |
| Multiple statements, conditionals, or loops | Block-bodied with { } |
| Method always returns the same constant | Expression-bodied |
A common mistake is reaching for => when the body has grown beyond a single expression and contorting the code to fit. If you find yourself nesting ternary operators three deep to keep the => form, that's a sign to go back to a block body. Readability wins over brevity.
The Methods Basics chapter showed => as one syntax option. This lesson uses it heavily: most return-anything-meaningful methods that are short enough fit the expression-bodied form, and the rest of the lesson uses it freely.
Methods that need to hand back several related values used to have three bad options: pack them into a custom class, use out parameters, or use the old System.Tuple<T1, T2> type with .Item1 and .Item2 properties. None of those read well.
C# 7 introduced value tuples, a lightweight syntax for grouping a handful of values without inventing a type. They look like this in a return position:
The return type (decimal subtotal, decimal tax, decimal total) declares a tuple with three named fields. The body builds those three values and returns them with the tuple literal (subtotal, tax, total). At the call site, order.subtotal, order.tax, and order.total give back each piece by name.
The underlying type is System.ValueTuple<T1, T2, T3>, a struct. That makes value tuples cheap, they live on the stack like other structs and don't allocate on the heap. The names exist mostly for the compiler and the IDE; at runtime, the same data is also accessible through .Item1, .Item2, .Item3 if you ever need them.
Names are optional but almost always worth adding. Compare an unnamed tuple with a named one:
A call site shows the difference:
Both produce the same numbers, but the named version tells you what each one means without having to look up the method. For anything beyond a one-off pair, name the fields.
Reading order.subtotal works, but it's still wrapping the values in a tuple variable. Often you'd rather pull them straight into separate local variables. That's what deconstruction does:
Three useful forms:
var (a, b, c) = ... introduces three new locals with inferred types.(int a, int b, int c) = ... introduces three new locals with explicit types.(a, b, c) = ... (no var, no types) assigns into variables that already exist.You can't mix the first two on the same line. Either every name gets var-style inference (one var for the whole tuple, not one per name) or every name carries an explicit type.
The variable names on the left don't have to match the tuple's field names. The deconstruction is positional, the first field of the tuple goes into the first variable on the left regardless of what either is called. That's error-prone and worth flagging:
What's wrong with this code?
The output is Subtotal: 8, Tax: 100, which is almost certainly not what the author intended. Deconstruction is by position, so tax on the left receives the first tuple field (the actual subtotal) and subtotal receives the second (the actual tax). The fix is to either keep the names in the same order as the tuple definition (var (subtotal, tax) = ...) or use member access instead (var order = ComputeOrder(); decimal subtotal = order.subtotal;).
When you only care about some of the tuple's fields, use the underscore _ (called a "discard") to ignore the rest:
A discard isn't a variable. It's a signal to the compiler that this slot exists but isn't worth a name. You can't read from _ after the deconstruction; the underscore doesn't even exist as a symbol. Using it makes the intent clearer than naming a variable unused or dummy and adds nothing to memory because the discarded value isn't stored anywhere.
You can have as many discards in a single deconstruction as you like, even all of them, though all-discards is a sign the method call has no useful effect.
A method takes parameters in, runs some logic, and returns one value out. With tuples, that "one value" can carry several pieces inside it, but it's still a single return.
The blue nodes on the left are the parameters flowing in. The orange node is the method itself. The green node is the single returned tuple. The teal nodes are the named fields the caller pulls out of that tuple via deconstruction or property access. There's still exactly one return value; the tuple is just the wrapper around the three pieces.
The _ref, out & in Parameters_ lesson covered out parameters, which let a method assign through a caller-supplied location. Before tuples existed, that was the standard way to return more than one value. Both still work; the question is which to use when.
A side-by-side rewrite of the order calculation makes the differences clear:
Both produce identical results. The differences are in the ergonomics and the situations each one fits best:
| Aspect | Tuple return | out parameters |
|---|---|---|
| Caller syntax | One expression, can be used inline | Pre-declared (or inline out var) variables |
| Method signature | Return type carries the shape | Parameter list grows with each output |
| Use in chained calls | Yes, can be passed to another method | No, can't be chained directly |
| LINQ / lambda friendliness | Works naturally | Awkward, lambdas can't have out parameters easily |
Composition with async | Works with Task<(...)> returns | out is not allowed on async methods |
| Discard support | _ works directly | out _ works but is more verbose |
| Best fit | 2-4 related values that always come together | Try* patterns where one value is the result and others are diagnostics |
The most common idiomatic use of out today is the Try* pattern. The method returns a bool ("did it succeed?") and writes the actual value through an out parameter:
The standard library is full of this pattern: int.TryParse, Dictionary.TryGetValue, Queue.TryDequeue. It works well because the success bool and the value have different lifetimes, the value is only valid when the bool is true, and the call site naturally checks one before using the other.
Outside Try*, prefer tuples. They compose better with LINQ, async, and pattern matching, they don't pollute the parameter list, and they make the "what comes out" part of the contract live in the return type where it reads first.
A clean tuple-based validation example:
The return type spells out exactly what the caller will get back: a bool flag and a string explanation. That's much friendlier than a bool ValidateOrder(..., out string reason) signature, especially when the method has to be passed around as a value, used in LINQ, or returned from a higher-order method.
Value tuples are structs. Returning one is essentially free, like returning any other struct. They're allocated on the stack frame of the caller (or boxed only when stored in an object or non-generic collection).
Methods often return collections of items or custom-shaped data. Those return types work exactly like primitives from the method's point of view, the only thing that changes is what callers can do with the result.
The .Sum() call uses LINQ. The Arrays section and the Collections section in later chapters go into return-type choices for collections (returning IEnumerable<T> vs IReadOnlyList<T> vs List<T>, deferred vs immediate execution). For this lesson, the point is just that the return-type machinery doesn't care: a List<decimal> returns the same way as a decimal.
For shapes that are reused across many methods, a class or struct beats a tuple. Tuples are great for one-off returns, but giving the shape a name (Order, CartSummary, ValidationResult) is clearer once it shows up in five or six places.
A short note on a topic that comes up often: C# 9 introduced records, which are a compact syntax for declaring small immutable data types with value-based equality. They're a natural fit for return shapes that need names. The tuple-vs-record decision comes down to whether the shape is one-off (tuple) or reusable and worth naming (record or class). Either way, the return-keyword mechanics are identical: build the value, hand it back, the caller uses it.