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

📚 Interview question

A colleague wrote an iterator method and an async method that validate their arguments, but ArgumentNullException is only thrown when the result is enumerated or awaited, far from the bad call. Why does this happen, and how do local functions fix it? Also explain static local functions and when to prefer a local function over a lambda or private method.

Local functions in practice: eager validation for iterators and async methods, capture without allocation, static local functions, recursion, and choosing between local functions, lambdas and private methods.

Mid-Level Debugging Intermediate async csharp debugging iterators local-functions

🎯 Key points

  • Iterator methods run no code until the first MoveNext, so validation inside them is deferred
  • Async methods capture exceptions into the returned Task; the caller sees them only when awaiting
  • Pattern: a non-iterator, non-async outer method validates eagerly and returns a call to a local iterator or async function
  • Local functions can capture locals; the compiler uses a struct closure passed by ref when no delegate is created, avoiding heap allocation
  • `static` local functions (C# 8) cannot capture, making dependencies explicit
  • Local functions support recursion, generics, ref/out parameters, iterators and async, and can be declared after use
  • Prefer a local function over a lambda when you just call it; prefer a private method when it is reused or needs its own tests

⏱ 30-second answer

An iterator method runs none of its body until the first MoveNext, so a ThrowIfNull at the top only fires when someone enumerates. An async method stores the exception in the returned task, so it only shows up when awaited. The fix is to keep the public method non-iterator and non-async: validate there, then return a call to a local iterator or local async function. That's exactly how LINQ validates eagerly. Local functions can capture variables without allocating when they aren't turned into delegates, and marking them static forbids capture altogether. I use them for private helpers that only make sense inside one method.

🗣 Full answer (senior)

How I would answer this in an interview

Both iterator methods (yield return) and async methods (async) are compiled into state machines that run the method body later: an iterator's body starts on the first MoveNext(), and an async method's exceptions are captured into the returned Task. So argument validation inside the body doesn't throw at the call site — it throws when the result is enumerated or awaited, often far away. The fix is to split the method: validate eagerly in the outer method, and put the lazy/async work in a local function. I'd also explain static local functions and when to prefer local functions over lambdas or private methods.

1. Why the exception is late

Iterators:

public static IEnumerable<string> ReadLines(string path)
{
    ArgumentException.ThrowIfNullOrEmpty(path);   // runs only on first MoveNext()!
    foreach (var line in File.ReadLines(path)) yield return line;
}

var lines = ReadLines(null!);   // no exception here
// ... 200 lines later ...
foreach (var l in lines) { }    // ArgumentException thrown HERE

The compiler turns the whole body — including the guard — into MoveNext() of a generated class. Calling ReadLines just creates the iterator object.

Async methods:

public async Task<Order> GetAsync(Guid id, CancellationToken ct)
{
    ArgumentOutOfRangeException.ThrowIfEqual(id, Guid.Empty);   // exception stored in the Task
    return await _repo.FindAsync(id, ct);
}

var task = GetAsync(Guid.Empty, ct);   // no exception thrown here
...
var order = await task;                // thrown here (or never, if the task isn't awaited)

In an async method, all exceptions (even before the first await) are captured into the returned task. Fire-and-forget callers never see them; Task.WhenAll callers see them later.

2. The fix: local functions

public static IEnumerable<string> ReadLines(string path)
{
    ArgumentException.ThrowIfNullOrEmpty(path);           // eager: runs at call time
    return Iterate(path);

    static IEnumerable<string> Iterate(string path)        // lazy part
    {
        foreach (var line in File.ReadLines(path)) yield return line;
    }
}

public Task<Order> GetAsync(Guid id, CancellationToken ct)  // NOT async
{
    ArgumentOutOfRangeException.ThrowIfEqual(id, Guid.Empty);  // throws synchronously to the caller
    return GetCoreAsync(id, ct);

    async Task<Order> GetCoreAsync(Guid id, CancellationToken ct) => await _repo.FindAsync(id, ct)
        ?? throw new OrderNotFoundException(id);
}

The BCL uses exactly this pattern (LINQ operators validate eagerly; many async APIs validate synchronously). Note: for async methods, some teams prefer exceptions in the task for consistency (callers handle all errors at await); the key is to be deliberate. Argument/usage errors (bugs) are best thrown eagerly.

3. Static local functions

static local functions (C# 8) can't capture locals, parameters or this — everything must be passed explicitly. Benefits:

  • No accidental closure allocations or captures (especially important for iterators/async helpers, where captured state is hoisted into the state machine anyway).
  • Clear data flow: the helper's inputs are its parameters.
  • Analyser IDE0062 suggests static when possible.

4. Local function vs lambda vs private method

Use When
Local function Helper used only in this method; iterator/async splitting; recursion; when you want no delegate allocation (direct calls to local functions capture via struct, not heap closure)
Lambda You need a delegate to pass somewhere (LINQ, callbacks, Task.Run), or a short inline expression
Private method Reused by multiple members, deserves its own name at class level, or is independently testable/significant

Local functions also support generics, ref/out params, attributes, yield and async — things lambdas can't (or can only partially) do.

5. A production example

A file-import service's IEnumerable<Record> Parse(Stream stream) validated stream.CanRead inside the iterator. A caller passed a disposed stream; the exception surfaced in a background Parallel.ForEach minutes later, logged as an ObjectDisposedException with a stack trace pointing at the enumeration, not the caller. Splitting validation from iteration with a local function made the bug fail at the call site in a unit test. We applied the same pattern to async service methods so invalid IDs throw before any work is scheduled.

🔍 What the interviewer is checking

A debugging question that checks understanding of iterator and async state machines.

Green flags: explains why validation is deferred in both cases; shows the outer/inner split; knows struct closures and static local functions; reasons about lambda versus local function versus private method.

Red flags: blames the caller; suggests calling ToList inside the iterator; does not know async stores exceptions in the Task.

📚 Learn in depth

1. Iterator state machine

// public IEnumerable<string> ReadLines(string path) => new <ReadLines>d__0(-2) { <>3__path = path };
private sealed class <ReadLines>d__0 : IEnumerable<string>, IEnumerator<string>
{
    private int <>1__state;
    private string <>2__current;
    public string <>3__path;
    bool IEnumerator.MoveNext()
    {
        switch (<>1__state)
        {
            case 0:
                <>1__state = -1;
                ArgumentException.ThrowIfNullOrEmpty(path);   // guard lives here
                ...
        }
    }
}

The method returns the object immediately; the guard runs when MoveNext first executes.

2. Async state machine

public Task<Order> GetAsync(Guid id, CancellationToken ct)
{
    var sm = new <GetAsync>d__0 { <>t__builder = AsyncTaskMethodBuilder<Order>.Create(), id = id, ct = ct, <>1__state = -1 };
    sm.<>t__builder.Start(ref sm);          // runs MoveNext synchronously until the first incomplete await
    return sm.<>t__builder.Task;
}

MoveNext wraps the body in try/catch; any exception calls builder.SetException(ex), faulting the task. So even synchronous exceptions become task faults.

3. Local functions and capture

A non-static local function that captures locals and is only called directly uses a struct closure passed by ref (no heap allocation). But iterator or async local functions must hoist captured variables into their state machine (heap) — another reason to pass values as parameters and make them static.

4. Stack traces

Modern .NET stack traces show local functions as ReadLines(String path)+Iterate(String path) (demangled from <ReadLines>g__Iterate|0_0) and async frames cleanly, so splitting doesn't hurt diagnostics.

5. Choosing exception timing for async APIs

  • Usage errors (null arguments, invalid enum values): throw synchronously — they're bugs, and early failure is clearer.
  • Operational errors (not found, I/O failures, cancellation): put them in the task — callers handle them at await.
  • Microsoft's guidelines for TAP methods recommend synchronous throwing only for usage errors.

6. ValueTask and eager paths

For hot async methods, the split pattern also enables synchronous fast paths: the non-async outer method can return ValueTask.FromResult(cached) without allocating a state machine, delegating to the async local function only when I/O is needed.

Try it yourself

  1. Write an iterator with a guard in the body, call it with bad input and enumerate later; then split it.
  2. Write an async method with a guard and call it without awaiting.
  3. Mark the local function static and fix compile errors by passing parameters.
  4. Inspect the generated state machines in SharpLab.

💻 Code

namespace Advanced.LocalFunctions;

public sealed record Row(int LineNumber, string[] Fields);

public static class CsvReader
{
    // BUG: validation deferred until enumeration
    public static IEnumerable<Row> ReadRowsDeferred(TextReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        var line = 0;
        while (reader.ReadLine() is { } text) yield return new Row(++line, text.Split(','));
    }

    // FIX: eager validation, lazy iteration
    public static IEnumerable<Row> ReadRows(TextReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        return Iterator(reader);

        static IEnumerable<Row> Iterator(TextReader r)   // static: no accidental captures
        {
            var line = 0;
            while (r.ReadLine() is { } text) yield return new Row(++line, text.Split(','));
        }
    }

    // Same idea for async
    public static Task<int> CountRowsAsync(TextReader reader, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(reader);
        return Core();

        async Task<int> Core()                            // captures reader and ct without allocation
        {
            var count = 0;
            while (await reader.ReadLineAsync(ct) is not null) count++;
            return count;
        }
    }

    // Recursion and generics are natural in local functions
    public static int Depth<T>(IReadOnlyDictionary<T, T> parentOf, T node) where T : notnull
    {
        return Walk(node);

        int Walk(T current) => parentOf.TryGetValue(current, out var parent) ? 1 + Walk(parent) : 0;
    }
}

public static class Program
{
    public static async Task Main()
    {
        var deferred = CsvReader.ReadRowsDeferred(null!);    // no exception yet
        try { deferred.First(); } catch (ArgumentNullException) { Console.WriteLine("Thrown late, at enumeration"); }

        try { CsvReader.ReadRows(null!); } catch (ArgumentNullException) { Console.WriteLine("Thrown at the call"); }

        using var reader = new StringReader("a,b\nc,d\n");
        Console.WriteLine(await CsvReader.CountRowsAsync(reader, CancellationToken.None)); // 2

        var parents = new Dictionary<string, string> { ["leaf"] = "branch", ["branch"] = "root" };
        Console.WriteLine(CsvReader.Depth(parents, "leaf"));  // 2
    }
}

⚠ Common mistakes

1. Guards inside iterator bodies

What happens: argument checks in methods with yield. Why it is wrong: exceptions surface on enumeration, far from the bug. Do instead: validate in an outer method; iterate in a local function.

2. Expecting async methods to throw synchronously

What happens: try/catch around a call without await. Why it is wrong: exceptions are stored in the task. Do instead: await the task inside the try, or split usage-error validation into a non-async wrapper.

3. Fire-and-forget with faulted tasks

What happens: _ = GetAsync(badId); never observed. Why it is wrong: errors silently vanish. Do instead: await tasks or use background queues with error handling.

4. Capturing state in async/iterator local functions

What happens: local functions capture locals and this. Why it is wrong: hoisted into heap state machines; harder to reason about. Do instead: make them static and pass parameters.

5. Using lambdas where delegates aren't needed

What happens: Func<...> helper = ... for internal helpers. Why it is wrong: delegate and closure allocations; no yield. Do instead: use local functions.

6. Promoting everything to private methods

What happens: single-use helpers at class level. Why it is wrong: clutters the class and separates logic from its only use. Do instead: use local functions for single-use helpers.

7. Throwing operational errors synchronously

What happens: "not found" thrown before returning a task. Why it is wrong: inconsistent error handling for callers. Do instead: reserve synchronous throws for usage errors.

8. Very large local functions

What happens: methods with many large local functions. Why it is wrong: hides that the method does too much. Do instead: extract a class or private methods.

🏛 Senior insight: trade-offs

The eager-validation split is the canonical reason local functions exist, and it is a strong signal in interviews because it combines knowledge of iterator and async state machines. Beyond that, local functions are a tidy way to keep helpers private to one method with zero allocation for captures. I mark them static when they do not need captures, and I promote them to private methods when they grow or need direct tests.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions static local functions. Why: Checks the local function basics and static local functions.
    How to answer

    A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.

  2. When: Candidate mentions yield return and deferred exceptions. Why: Iterator methods are the classic reason to split validation into a local function.
    How to answer

    A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.

  3. When: Candidate says the exception appears only when the sequence is enumerated. Why: Deferred execution explains why the exception surfaces late.
    How to answer

    IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.

🛠 Real-world problems: how you handle them

Bad import paths failed minutes later in a background worker
Situation
An import API accepted a file path, called `ReadRows(path)` (an iterator) and queued the result for a BackgroundService to process.
Signals
Requests returned 202 Accepted even for invalid paths; FileNotFoundException appeared in worker logs minutes later without the request's correlation ID.
Investigation
Reproduced with a unit test and saw no exception at the call; set a breakpoint inside the iterator and saw it first run inside the worker's foreach.
Resolution
Split ReadRows into an outer method that validates the path and opens the file, returning a local iterator; the controller now returns 400 for bad input.
Prevention
Added a test convention that public iterator methods throw on invalid arguments at call time, and an analyser check in code review.

How to tell it (STAR)

**S:** Invalid import requests were accepted and failed later in a background worker. **T:** Make the API fail fast. **A:** I found the validation lived inside an iterator, so it only ran when the worker enumerated it. I split the method into eager validation plus a local iterator. **R:** Bad requests now get a 400 immediately and the support team can tie errors to requests.

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me