Fluent API to await multiple calls and get their respective results

15/04/2024
C#.NETTasks

Sometimes, you have multiple async calls to make, and you want to do that asynchronously and get the results afterward. Let's build a fluent API to do that.

The problem

Imagine we have three API calls that can be done independently, and we want the results of each of the calls. We could do something like this:

var result1 = CallAPI1();
var result2 = CallAPI2();
var result3 = CallAPI3();

var result1Value = await result1;
var result2Value = await result2;
var result3Value = await result3;

That works, but it's not very elegant. Let's build a fluent API to do that. We want something like this:

var results = await TaskHelper.StartWith(() => CallAPI1())
    .And(() => CallAPI2())
    .And(() => CallAPI3())
    .WaitAllAsync();

Where results is a ValueTuple of the returned types, so we can also do this instead:

var (resultOne, resultTwo, resultThree) = await TaskHelper.StartWith(() => CallAPI1())
    .And(() => CallAPI2())
    .And(() => CallAPI3())
    .WaitAllAsync();

The solution

Let's build a structure that can fulfill the requirement. The idea is that each call needs its own object so that we are statically and strongly typed.

public static class TaskHelper
{
    public static TaskHelper<T> StartWith<T>(Func<Task<T>> task)
    {
        return new TaskHelper<T>(task);
    }
}

public class TaskHelper<T>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(Func<Task<T>> initialTask)
    {
        _tasks = [() => initialTask().ContinueWith(t => (object)t.Result)];
    }

    public TaskHelper<T, TNext> And<TNext>(Func<Task<TNext>> nextTask)
    {
        return new TaskHelper<T, TNext>(_tasks, nextTask);
    }

    public async Task<T> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return (T)results[0];
    }
}

public class TaskHelper<T1, T2>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(List<Func<Task<object>>> existingTasks, Func<Task<T2>> nextTask)
    {
        _tasks = [..existingTasks, () => nextTask().ContinueWith(t => (object)t.Result)];
    }

    public TaskHelper<T1, T2, T3> And<T3>(Func<Task<T3>> nextTask)
    {
        return new TaskHelper<T1, T2, T3>(_tasks, nextTask);
    }

    public async Task<(T1, T2)> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return ((T1)results[0], (T2)results[1]);
    }
}

public class TaskHelper<T1, T2, T3>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(List<Func<Task<object>>> existingTasks, Func<Task<T3>> nextTask)
    {
        _tasks = [..existingTasks, () => nextTask().ContinueWith(t => (object)t.Result)];
    }

    public async Task<(T1, T2, T3)> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return ((T1)results[0], (T2)results[1], (T3)results[2]);
    }
}

This way, we can chain as many calls as we want, and we will get the results in a strongly typed way. To support more And calls we would need another TaskHelper class with more generic parameters.

But the result looks like this: Compiler

We can see that the compiler knows exactly which type will be returned! And that all thanks to generics. We basically chain calls with one higher generic parameter each time, and the compiler can infer the types.

Thanks to @toupswork for the initial challenge and thought!

Resources

  • The code for this blog post on GitHub
  • All my examples for this blog found here
3
An error has occurred. This application may no longer respond until reloaded. Reload x