Railway Oriented Programming II
A code-along to Scott Wlaschin's talk on functional error handling
In the last post, I talked at a high-level about Scott Wlaschin's "Railway Oriented Programming" (ROP)1 analogy for applying a functional style to things like error checking and exception handling.
Here, I'd like to enumerate some of the code samples I built in C# as I "coded along" at home. I haven't yet compared these to any other online code samples on the topic, but you can check my work and see some extra resources on the topic on his ROP page here. There, he also issues a warning that this approach isn't always recommended, and has some downsides, too. I'll leave that analysis for another day.
Error-generating functions
An error-generating function acts as a "switch." A TIn
input yields either a success type TOut
output (the normal "happy path" output), or an Error
:
SuccessOrError ErrorGeneratingFunction(TIn input) { ... }
Instead of returning a "lowest common denominator" C# object
type which could contain either result, we can use a discriminated union or "choice" type to represent the output with strong(er) typing. Here's a minimal implementation of an "A or B" Choice
type:
public class Choice<A, B>
{
public Choice(A value) { Value = value; }
public Choice(B value) { Value = value; }
public dynamic Value { get; }
// equality and string representation delegated to member (Value)
public override bool Equals(object? obj) => Value.Equals(obj);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
}
I say "stronger" typing because the Value
parameter is still dynamically typed. (True Discriminiated Unions, a core feature of F#, appear to be coming in C# , which is exciting.) With this in place, the Result
type becomes:
// Result implementation
class Result<TOut> : Choice<TOut, Error>
{
public static implicit operator Result<TOut>(TOut value) => new Result<TOut>(value);
public static implicit operator Result<TOut>(Error error) => new Result<TOut>(error);
public Result(TOut value) : base(value) { }
public Result(Error error) : base(error) { }
}
As a convenience, I added implicit conversions from either of the two individual output types (TOut
and Error
, here), into the Result<TOut>
type.
Using this structure, the signature of the error-generating switch function becomes:
// switch function signature in Func-form
Func<TIn, Result<TOut>> switchFunction;
// ...and an example method
Result<float> MaybeInverse(float val) => val switch
{
0f => new Error("Inverse!"),
_ => (Result<float>)(1 / val)
};
Bind
Bind is an "adapter block" that glues these error-generating functions together into a two-track model, so that the happy paths compose, and the failure path short-circuits by forwarding any previous Errors.
// Bind signature in Func-form
Func<Func<TIn, Result<TOut>>, Func<Result<TIn>, Result<TOut>> bind;
// ...and implemented as a method
Func<Result<TIn>, Result<TOut>> Bind<TIn, TOut>(Func<TIn, Result<TOut>> switchFunction)
{
return choice => choice.Value switch
{
Error e => (Result<TOut>)e,
TIn tIn => switchFunction(tIn).Value switch
{
Error eOut => eOut,
TOut tOut => tOut
}
};
}
Map
A map is an adapter block that turns (or "lifts") a 1-track function into a two-track function2:
// Map signature in Func-form
Func<Func<TIn, TOut>, Func<Result<TIn>, Result<TOut>> map;
// ...and implemented as a method (in terms of bind)
Func<Result<TIn>, Result<TOut>> Map<TIn, TOut>(Func<TIn, TOut> oneTrackFunction)
{
return Bind<TIn, TOut>(input => oneTrackFunction(input));
}
Tee
Tee is an adapter block that turns a "dead-end" function (or Action) into a one-track function:
// Tee signature in Func-form
Func<Action<TIn>, Func<TIn, TIn>> tee;
// ...and implemented as a method
Func<TIn, TIn> Tee<TIn>(Action<TIn> deadEndFunction)
{
return (TIn input) => { deadEndFunction(input); return input; };
}
These are "side-effect" functions that might write to the database, send an email, log an action, print to screen, etc. Now that we have a one-track function, we can use Map to turn it into a two track function. Let's call that composite capability "Audit":
Audit
// Audit signature in Func-form
Func<Action<TIn>, Func<Result<TIn>, Result<TIn>>> audit;
// ...and implemented as a method (in terms of Map and Tee)
Func<Result<TIn>, Result<TIn>> Audit<TIn>(Action<TIn> deadEndFunction)
{
return Map(Tee(deadEndFunction));
}
// ...or alternatively, directly in terms of Bind
Func<Result<TIn>, Result<TIn>> Audit<TIn>(Action<TIn> deadEndFunction)
{
return Bind<TIn, TIn>(input => { deadEndFunction(input); return input; });
}
A supervisory or "audit" function is transformed to a two-track function by just passing Success and Failure inputs straight through to the outputs, and performing Actions on them along the way as needed.
lightw8 comment: You could imagine an enhancement to the Tee method to return a Failure (instead of void), if the action fails. But, then it's "just" an error-generating function (a good thing!), and we already have Bind for that. Speaking of which...
Exceptions
In this model the guidance is to "catch and don't release" exceptions that are relevant to this level of the computation, and return them instead as a formal Failure result. It's a judgement call about which exceptions to let propagate up the stack, but we should handle things that are reasonably in-scope. We don't want the code blowing up for things we have context for fixing/handling, but we also don't want to guess at the intent of the caller.
Validation, Events, Undo, Retries
Around minute 37 of the talk, Mr. Wlaschin talks about parallel validation logic, and this leads to a general discussion on how we can augment the Result
structure to contain additional information on the success path, such as store/forward event data and allow for rollback/undo types of actions. Similarly, enhancing the error path can be used to enable retries in the case of errors. I won't elaborate further for now, but this might be interesting to revisit sometime in more detail.
Final thoughts
I enjoyed the challenge of creating these implementations without checking outside sources. So caveat emptor - no guarantees your trains will arrive on time with this code! In the next post, I'll work through a few examples of how we can use these functional building blocks in practice.
Thanks for reading, and talk soon. -David
1 Concept and header image credit to Scott Wlaschin here
2 C# type inference looks like it will be improving in C# 10, but for now, we need to explicitly specify the generic types when calling Bind<TIn, TOut>(...)