📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do out parameters work in C#, and when should you use them? Explain definite assignment, out var and discards, the Try pattern, how out relates to ref under the hood, why async methods and iterators cannot have out parameters, nullable annotations for Try methods, and when a tuple or result type is a better choice.

out parameters: semantics, Try pattern, limits and alternatives such as tuples and result types.

Junior Theory Intermediate api-design nullable out-parameters try-pattern tuples

🎯 Key points

  • out passes a reference the callee must assign before returning; the caller need not initialise it
  • out var and discards make call sites concise
  • The Try pattern returns bool and an out result for expected failures without exceptions
  • Under the hood out is a ref with an Out attribute, so no copy is made
  • Async methods and iterators cannot have out or ref parameters
  • Annotate Try methods with NotNullWhen(true) or MaybeNullWhen(false)
  • Prefer tuples, records or result types for multiple outputs, async code or failure reasons

⏱ 30-second answer

An out parameter passes a reference to the caller's variable that the method must assign before returning; the caller need not initialise it. The compiler enforces definite assignment on both sides. C# 7 added out var (declare at the call site) and discards (out _). The classic use is the Try pattern: bool TryParse(string s, out int value) returns success and the result without exceptions. Under the hood out is a ref with an [Out] attribute, so no copy is made. Async methods and iterators cannot have out (or ref) parameters, because the method returns before the value would be written. Annotate Try methods with [NotNullWhen(true)] or [MaybeNullWhen(false)]. Prefer a tuple or result type when there are several outputs, when you need async, or when failure needs a reason.

🗣 Full answer (senior)

How I would answer this in an interview

An out parameter lets a method return an extra value through a reference to the caller's variable. The callee must assign it before returning, and the caller's variable is definitely assigned afterwards. Its main legitimate use today is the Try pattern (bool TryParse(string s, out int value)). I'd cover definite assignment, out var and discards, the Try pattern, how out relates to ref, why async/iterators can't have them, nullable annotations, and when tuples or result types are better.

1. Definite assignment

public static bool TryParsePostcode(string? input, out int postcode)
{
    postcode = 0;                                   // must assign on every non-throwing path (CS0177)
    if (input is not { Length: 4 }) return false;
    return int.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out postcode);
}
  • Callee: the out parameter starts unassigned; reading it before assignment is CS0269; every return path must assign it.
  • Caller: doesn't need to initialise the variable; it's definitely assigned after the call.

2. out var and discards

if (int.TryParse(text, out var quantity)) Use(quantity);   // declare inline (C# 7)
if (dict.TryGetValue(key, out _)) { ... }                  // discard when you don't need the value
if (DateOnly.TryParse(s, out DateOnly date)) { ... }       // explicit type

The variable's scope is the enclosing statement/block (it "leaks" into the containing scope for if statements), which allows if (!int.TryParse(s, out var n)) return; Use(n);.

3. The Try pattern

The BCL's convention for operations where failure is expected and common:

  • bool TryXxx(input, out T result): returns true + result on success, false (with default result) on failure — no exceptions.
  • Paired with a throwing Xxx(input) for callers who treat failure as exceptional.
  • Examples: int.TryParse, Dictionary.TryGetValue, ConcurrentQueue.TryDequeue, Enum.TryParse, IPAddress.TryParse, TryFormat(Span<char>, out int written).

This avoids exceptions in hot paths (parsing user input) while keeping the API simple.

4. How out relates to ref under the hood

In IL, out and ref are the same: a managed pointer (T&) to the caller's storage. out adds [Out] metadata and compiler-enforced definite-assignment rules. Consequences:

  • You can't overload M(out int) and M(ref int) — same signature at the CLR level.
  • The callee can technically read through the pointer (the CLR doesn't stop it), but C# forbids reading before assigning.
  • Passing a field or array element by out lets the callee write directly into it.

5. Why async methods and iterators can't have out (or ref/in) parameters

Async methods and iterators are compiled into state machines whose locals are hoisted into heap objects so they survive across await/yield. An out parameter is a managed pointer that may point to the caller's stack frame; storing it in a heap object would allow it to outlive that frame — a dangling reference. So C# forbids it (CS1988). Async "Try" APIs instead return tuples or result types: Task<(bool Found, Order? Order)>, ValueTask<Order?>.

6. Nullable annotations for Try methods

public bool TryGetCustomer(int id, [NotNullWhen(true)] out Customer? customer) { ... }
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) { ... }   // generic: default on false

if (repo.TryGetCustomer(id, out var c)) Console.WriteLine(c.Name);   // no warning, no '!'
  • [NotNullWhen(true)] on a nullable out reference: non-null when the method returns true.
  • [MaybeNullWhen(false)] on a generic out T: may be default when returning false.
  • Without them, callers get nullable warnings and start sprinkling !.

7. When a tuple or result type is better

  • Multiple return values that aren't the Try pattern — out parameters for several outputs are awkward; return a tuple (private) or record (public).
  • Async methods — out isn't allowed.
  • Failures with reasons (not just true/false) — a result type (Result<T, Error>) or discriminated records carry the error.
  • Fluent/LINQ code — out doesn't compose in expressions; tuples do.
  • Public APIs that may evolve — adding another output to a record is easier than adding an out parameter.

I keep out for: Try patterns, low-level performance APIs (TryFormat(..., out int charsWritten)), and interop.

8. A production example

An API's ValidateOrder(Order o, out string error, out int errorCode, out bool isWarning) was called from new async code, forcing awkward wrappers. We replaced it with ValidationResult Validate(Order o) (a record with a list of errors and severity), which worked naturally in async handlers and in ASP.NET Core's ValidationProblem mapping. We kept TryParseOrderNumber(string, [NotNullWhen(true)] out OrderNumber?) for the hot parsing path.

🔍 What the interviewer is checking

The interviewer checks basic C# parameter semantics and API judgement.

Green flags

  • Explains definite assignment rules and out var/discards.
  • Uses the Try pattern for expected failures and knows why.
  • Knows out cannot be used in async methods and suggests alternatives.
  • Adds nullable attributes to Try methods.

Red flags

  • Uses exceptions for control flow on normal input.
  • Confuses out with ref.
  • Uses many out parameters instead of a return type.

📚 Learn in depth

1. IL details

bool TryParse(string s, out int value)
// .method ... bool TryParse(string s, [out] int32& value)

Calls pass the address of the caller's variable (ldloca.s value). The JIT may keep the value in a register after inlining. For struct out parameters, the callee writes directly into the caller's storage — no copy on return.

2. Definite assignment analysis

The compiler's flow analysis treats out parameters as unassigned at method entry and requires assignment before any normal return. Throwing paths don't need assignment. After the call, the caller treats the argument as assigned — even if the method returned false (it will hold whatever the callee assigned, typically default).

3. out with ref struct and spans

out Span<T> parameters are allowed in synchronous methods but subject to ref safety (the callee can't assign a span over its own stack memory to the caller's variable).

4. Performance of Try vs exceptions

Approach Cost on failure
TryParse returning false Nanoseconds
Parse + catch FormatException Microseconds+ (exception throw/catch), allocations

For user input and external data, failures are common — Try patterns are the right default.

5. Designing Try methods

  • Return bool; put the result in the last out parameter.
  • Assign default on failure.
  • Throw only for programming errors (null arguments), not for the expected failure.
  • Add nullable attributes.
  • Consider ReadOnlySpan<char> overloads for parsing to avoid allocations.

6. Alternatives in async code

public async Task<(bool Found, Order? Order)> TryFindAsync(OrderId id, CancellationToken ct) { ... }
var (found, order) = await repo.TryFindAsync(id, ct);

public async Task<Order?> FindAsync(OrderId id, CancellationToken ct) { ... }   // often simplest: null = not found

Try it yourself

  1. Write a Try method and remove an assignment on one path to see CS0177.
  2. Add [NotNullWhen(true)] and observe nullable warnings disappear at call sites.
  3. Try declaring async Task<bool> TryX(out int v) and read CS1988.
  4. Benchmark TryParse vs Parse + catch for invalid inputs.

💻 Code

using System.Diagnostics.CodeAnalysis;
using System.Globalization;

public readonly record struct PromoCode(string Prefix, int Percent);

public sealed record Customer(int Id, string Name);

public static class Promo
{
    // Try pattern: expected failure, no exceptions
    public static bool TryParse(string? input, out PromoCode code)
    {
        code = default;                                  // definite assignment on failure paths
        if (input is not { Length: >= 4 }) return false;
        ReadOnlySpan<char> s = input;
        int dash = s.IndexOf('-');
        if (dash <= 0) return false;
        if (!int.TryParse(s[(dash + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out int percent)
            || percent is < 1 or > 90) return false;
        code = new PromoCode(s[..dash].ToString(), percent);
        return true;
    }
}

public sealed class CustomerCache
{
    private readonly Dictionary<int, Customer> _items = new() { [1] = new(1, "Mia") };

    public bool TryGet(int id, [NotNullWhen(true)] out Customer? customer)
        => _items.TryGetValue(id, out customer);

    // async cannot use out: return a tuple instead
    public async Task<(bool Found, Customer? Customer)> FindAsync(int id)
    {
        await Task.Yield();
        return TryGet(id, out var c) ? (true, c) : (false, null);
    }
}

public static class Program
{
    public static async Task Main()
    {
        foreach (string input in new[] { "SPRING-20", "BAD", "WINTER-95" })
            Console.WriteLine(Promo.TryParse(input, out var code) ? $"{code.Prefix} {code.Percent}%" : $"invalid: {input}");

        var cache = new CustomerCache();
        if (cache.TryGet(1, out var mia)) Console.WriteLine(mia.Name);   // no '!' thanks to NotNullWhen
        if (!cache.TryGet(2, out _)) Console.WriteLine("2 not cached"); // discard

        var (found, customer) = await cache.FindAsync(1);
        Console.WriteLine($"{found} {customer?.Name}");
    }
}

⚠ Common mistakes

1. Multiple out parameters for complex results

What happens: methods with three or four out parameters. Why it is wrong: awkward, unreadable and impossible in async code. Do instead: return a record or result type.

2. Throwing from Try methods for expected failures

What happens: TryParse that throws on bad input. Why it is wrong: defeats the pattern's purpose. Do instead: return false for expected failures.

3. Missing nullable attributes

What happens: out Customer? c without [NotNullWhen(true)]. Why it is wrong: false warnings and ! usage at call sites. Do instead: annotate.

4. Using the out value when the method returned false

What happens: reading value regardless of the result. Why it is wrong: it's default, not meaningful. Do instead: use it only on success.

5. Trying to use out in async methods

What happens: compile errors lead to workarounds like mutable holder objects. Why it is wrong: convoluted code. Do instead: return tuples, nullable results or result types.

6. Not assigning out on all paths

What happens: compile error CS0177. Why it is wrong: the contract requires assignment. Do instead: assign default early.

7. Overloading on ref vs out

What happens: M(ref int) and M(out int). Why it is wrong: not allowed — same CLR signature. Do instead: different method names.

8. Using out where a return value suffices

What happens: void GetTotal(out decimal total). Why it is wrong: unnecessary complexity. Do instead: return the value.

🏛 Senior insight: trade-offs

out is best seen as an API design choice for the expected-failure path. The Try pattern is idiomatic .NET, cheap, and clear when there is one result.

Failure type API style
Expected and frequent (parsing, lookups) TryX(out)
Exceptional (bugs, broken invariants, I/O failure) exceptions
Expected with a reason the caller needs result type
Async expected failure Task<T?> or result type

On hot paths, out also has a mechanical advantage: the callee writes straight into the caller's storage, so large struct results are not copied. Senior engineers use that knowingly, not as a default.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares out and ref. Why: Checks the parameter modifiers overview.
    How to answer

    C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.

  2. When: Candidate suggests returning a tuple. Why: Tuples are the alternative for several outputs and async.
    How to answer

    C# tuples like (int Id, string Name) are System.ValueTuple structs with public, mutable fields Item1 and Item2. The names you write exist only at compile time: they're stored in a TupleElementNames attribute, so reflection, serialisers and dynamic only see Item1 and Item2. Tuples have value equality and support ==, and since names aren't part of the type, (int A, int B) converts to (int X, int Y). System.Text.Json ignores fields by default, so a tuple serialises as {}. I use tuples for private multiple returns, and records for anything public or serialised.

  3. When: Candidate mentions NotNullWhen. Why: NotNullWhen on Try methods is a nullable annotation topic.
    How to answer

    Nullable reference types are compile-time flow analysis, not a run-time guarantee, so every ! and = default! is a promise the compiler cannot check. In review I remove ! that hides real nulls, replace = default! with required members or constructor parameters, and teach the compiler my helpers' contracts with attributes: [NotNullWhen(true)] on Try methods, [MemberNotNull] on initialisers, [NotNullIfNotNull], [DoesNotReturn] on throw helpers. For generics, T? on an unconstrained T means "may be default". At boundaries the compiler cannot see (JSON, EF Core queries, reflection, old libraries) I validate at run time, because a non-nullable string can still hold null there. With NRT on, EF Core also maps non-nullable reference properties to NOT NULL columns, so annotations change your schema.

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me