async
LINQ
lambda
C#
programming

Why doesn't an async LINQ Select lambda require a return value

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

An async lambda used with LINQ Select does require a return value, but the value might be implicit in expression-bodied syntax. The confusion usually comes from how the compiler infers delegate types for async lambdas. Select always performs projection, so each source element must map to some result type, often Task<T> in async scenarios.

Select Always Projects

Enumerable.Select expects a function from input to output. With async lambdas, that output is usually a task.

csharp
1using System;
2using System.Linq;
3using System.Threading.Tasks;
4
5class Program
6{
7    static async Task Main()
8    {
9        var values = new[] { 1, 2, 3 };
10
11        var tasks = values.Select(async x =>
12        {
13            await Task.Delay(10);
14            return x * 10;
15        });
16
17        int[] results = await Task.WhenAll(tasks);
18        Console.WriteLine(string.Join(",", results));
19    }
20}

The lambda clearly returns a value, and because it is async, the compiler wraps it into Task<int>.

Why It Looks Like No Return Is Needed

Expression-bodied async lambdas can hide explicit return.

csharp
var tasks = values.Select(async x => await ComputeAsync(x));

This still returns Task<int> if ComputeAsync returns Task<int>. The return is implicit.

Another valid pattern returns non-generic Task when no result value is needed.

csharp
var tasks = values.Select(x => LogAsync(x));
await Task.WhenAll(tasks);

Projection still occurs. The projected type is Task instead of Task<T>.

Compiler Inference and Delegate Types

The compiler chooses a delegate type based on context.

For Select:

  • Source type IEnumerable<TSource>.
  • Selector type Func<TSource, TResult>.

If selector body is async with return int, then TResult becomes Task<int>. If selector body has no value return, TResult becomes Task.

That is why "no return" seems possible. It is actually a return of a task type.

Common Correct Patterns

Pattern A, concurrent async projection:

csharp
var projectedTasks = items.Select(async item => await TransformAsync(item));
var transformed = await Task.WhenAll(projectedTasks);

Pattern B, sequential async processing:

csharp
1foreach (var item in items)
2{
3    await ProcessAsync(item);
4}

Use sequential flow when order and backpressure matter.

Pattern C, bounded concurrency:

csharp
1using System.Collections.Generic;
2using System.Threading;
3
4static IEnumerable<Task<string>> SelectWithLimit(IEnumerable<int> items, int limit)
5{
6    var gate = new SemaphoreSlim(limit);
7
8    return items.Select(async item =>
9    {
10        await gate.WaitAsync();
11        try
12        {
13            await Task.Delay(20);
14            return $"done-{item}";
15        }
16        finally
17        {
18            gate.Release();
19        }
20    });
21}

This avoids flooding external systems.

Avoid async void Traps

List<T>.ForEach expects Action<T>, not task-returning delegates. Passing async lambdas there creates async void, which breaks composition and exception handling.

csharp
// Avoid this style
myList.ForEach(async x => await ProcessAsync(x));

Prefer Select with Task.WhenAll, or plain foreach with await.

Deferred Execution Gotcha

LINQ query operators are deferred. Task creation may not start until you enumerate.

csharp
var query = values.Select(async x => await ComputeAsync(x));
// No execution yet.
var result = await Task.WhenAll(query); // execution starts here

If you need immediate materialization, call ToList before passing tasks onward.

SelectMany Is Different from Select

Some confusion comes from mixing projection operators. Select maps one input to one output item or task, while SelectMany flattens nested sequences. In async code, choose Select for task projection and flatten only after awaited completion when needed.

csharp
1var nested = new[] { "a,b", "c,d" }
2    .Select(s => s.Split(','))
3    .SelectMany(parts => parts);
4
5Console.WriteLine(string.Join("|", nested)); // a|b|c|d

Understanding this distinction makes async projection code easier to read and avoids type-inference confusion.

Common Pitfalls

  • Assuming async Select removes need for projection return types.
  • Confusing implicit expression-body return with no return.
  • Using List.ForEach async lambdas and losing exception flow.
  • Forgetting Task.WhenAll and leaving tasks unobserved.
  • Ignoring deferred execution when debugging start timing.

Summary

  • Async lambdas in Select still return projected values, usually tasks.
  • Expression-bodied lambdas can hide explicit return syntax.
  • Use Task.WhenAll for concurrent async projection.
  • Use sequential loops when order or backpressure is required.
  • Avoid async void patterns in collection iteration APIs.

Course illustration
Course illustration

All Rights Reserved.