Let's define a dangerous function as being one whose return type might be some failure state (such as a failed dictionary lookup) if something goes wrong. Dangerous functions can then all share a base type, say Result. Result has two type parameters - one for success and one for failure.

What does a dictionary lookup function type look like now?

Result<TValue, LookupError> GetValue(TKey key);

What is a LookupError? It could be as simple as a string saying 'lookup failed', but to make it easier to use we could include the name of the dictionary, the key we were looking for, and so on.

What's the range of this function? Simply put, either any possible value of type TValue, or any possible value of type LookupError. We can now say that, for any key in the domain of the function, it is mapped either to its corresponding value, or to its LookupError. Simple stuff.

What is Result? Probably an interface with two implementations. But what should I be able to do with a Result? Based on what we've said before, we should be able to default the value if we're in an error state at least. We should be able to do some logging or metrics based on whether the value exists. We should also be able to bubble the error if we can't recover from it, but these errors obviously don't bubble, so the best we can do is to make sure we can return the LookupError we're left with.

That last requirement is tricky, especially given type constraints. Let's look at some example code to disambiguate things.

ProcessStatus? GetStatus(Guid id)
{
  if(!_processes.TryGetValue(id, out var process))
  {
    _logger.Info($"Process with id '{id}' not found");
    return null;
  }
  
  return process.Status;
}

Note that I put single quotes around the id. This is a useful habit to get into, because it makes it much more obvious when you're dealing with a null value (or any value whose ToString happens to return empty or whitespace).

This code still sucks though. We've logged the problem but not exposed it to the caller, and we're introducing nulls where we shouldn't. Yikes. Ideally, GetStatus would also expose the errors it would run into, instead of letting the caller infer what's happened. It's not enough for me to simply replace both return types with Result however, because I need to transform the result between doing the dictionary lookup and returning: specifically, I need to access the Status property on the process. Let's add something into Result to let us do that.

interface Result<T, TErr>
{
  T DefaultIfEmpty(T defaultValue);
  
  Result<T, TErr> Then(Action<T> action);
  Result<T, TErr> Catch(Action<TErr> action);
  
  Result<TNew, TErr> Transform(Func<T, TNew> transformer);
}

Let's go through these in turn. DefaultIfEmpty returns either our success value or the parameter we pass in. Then runs the supplied action if we're in a success state, and Catch runs if we're in a failure state (this terminology is borrowed from Javascript). Transform creates a new Result with a new type by either transforming its success value or simply repackaging its error.

Hopefully the implementations of these functions should be immediate (it's nearly impossible to write something incorrect that successfully type-checks), but here they are.

class SuccessResult<T, TErr> : Result<T, TErr>
{
  private T _item;
  public SuccessResult(T item) => _item = item;
  
  T DefaultIfEmpty(T defaultValue) => _item;
  
  Result<T, TErr> Then(Action<T> action)
  {
    action(_item);
    return this;
  }
  
  Result<T, TErr> Catch(Action<TErr> _) => this;
  
  Result<TNew, TErr> Transform(Func<T, TNew> transformer) =>
    new SuccessResult<TNew, TErr>(transformer(_item));
}

class FailureResult<T, TErr> : Result<T, TErr>
{
  private TErr _error;
  public FailureResult(TErr error) => _error = error;
  
  T DefaultIfEmpty(T defaultValue) => defaultValue;
  
  Result<T, TErr> Then(Action<T> _) => this;
  
  Result<T, TErr> Catch(Action<TErr> action)
  {
    action(_error);
    return this;
  }
  
  Result<TNew, TErr> Transform(Func<T, TNew> transformer) =>
    new FailureResult<TNew, TErr>(_error);
}

C# is verbose, but hopefully we all agree the code above is remarkably simple. Our new GetStatus function is similarly simple.

Result<ProcessStatus, LookupError> GetStatus(Guid id) =>
  _processes.GetValue(id)
    .Catch(_ => _logger.Info($"Process with id '{id}' not found"))
    .Transform(process => process.Status);

What advantages does this code offer? For starters, we can only access variables we know are well-defined. We can include side effects wherever we like and we can trust that they'll only run if we're in the state we expect. In particular, we don't even need to care whether the lookup was successful. If it wasn't our log will happen. If it is, we'll select the Status. In both cases, we've saved all the available information and passed it up to the caller.


This is powerful, but not very powerful. We can do better. What if we need to chain dangerous calls? We can imagine a system where processes are stored in lists keyed by user.

Here's a useful helper function we could define for IEnumerables. If an element in the list matches the predicate it will return it; otherwise it will return a LookupError.

Result<Process, LookupError> Find(Predicate<Process> predicate);

The code now starts looking a bit less fun.

Result<Result<ProcessStatus, LookupError>, LookupError>
  GetStatus(User user, Guid id) =>
  _usersToProcesses.GetValue(user)
    .Catch(_ => _logger.Info($"No processes for user '{user}'"))
    .Transform(processes =>
      processes.Find(p => p.Id == id)
        .Catch(_ => _logger.Info(
          $"No process with id '{id}' found for user '{user}'"))
        .Transform(p => p.Status));

What went wrong? Many things. The return type is nonsense. We have to nest our second Catch and Transform. Our Transform function isn't able to handle cases where our transformation is itself a dangerous call that returns a Result. Further, it seems that only one of our LookupErrors is ever populated: if there is an error, it's because we either failed to find the list of processes for the user (and thus never called Find on a list), or we failed the Find (in which case the user lookup must've been successful). We can therefore capture all the information we need in a single Result.

Let's define a function called Bind (no one can tell me it's wrong to borrow terminology from both Javascript and Haskell in the same type).

interface Result<T, TErr>
{
  ...
  
  Result<TNew, TErr> Bind(Func<T, Result<TNew, TErr>> binder);
}

class SuccessResult<T, TErr> : Result<T, TErr>
{
  ...
  
  Result<TNew, TErr> Bind(Func<T, Result<TNew, TErr>> binder) =>
    binder(_item);
}

class FailureResult<T, TErr> : Result<T, TErr>
{
  ...
  
  Result<TNew, TErr> Bind(Func<T, Result<TNew, TErr>> _) =>
    new FailureResult<TNew, TErr>(_error);
}

The function is hard to understand at a conceptual level, but its implementation is extraordinarily simple. What does the code example look like now?

Result<ProcessStatus, LookupError> GetStatus(User user, Guid id) =>
  _usersToProcesses.GetValue(user)
    .Catch(_ => _logger.Info($"No processes for user '{user}'"))
    .Bind(processes => processes.Find(p => p.Id == id))
    .Catch(_ => _logger.Info(
      $"No process with id '{id}' found for user '{user}'")) 
    .Transform(p => p.Status);

I won't argue it looks like classic C#, but it meets all my requirements. Needless to say, few developers are going to look at this and think it's a necessity that must immediately be introduced to all codebases right away. There's also a small novelty that perhaps isn't apparent: if we fail to find processes for the user, we will log both that error and the following error. This isn't a dealbreaker (for some, it might even be beneficial to list the failed operations that occurred because of the previous failure), but I'm hard-pressed to say it's an example of the code's behaving properly.

I'll figure out how to fix this later.