Strings and monads

Two small groups that end a pipeline in a type other than IEnumerable<T>.

JoinToString and ConcatToString turn a sequence into a string; they exist because string.Join and string.Concat are static methods that read inside out at the end of a query.

Sequence and Traverse turn a sequence of monadic values into a monadic value of a sequence. They are how the Option, Result and Either monads scale from one value to many: every element must succeed, or the whole thing fails with the first failure.

JoinToString

string.Join as an extension method, so it can be the last step of a pipeline.

string JoinToString<TSource>(this IEnumerable<TSource> source, char separator)
string JoinToString<TSource>(this IEnumerable<TSource> source, string separator)
string JoinToString(this IEnumerable<string?> source, string separator)

Each element is converted with ToString, and the separator goes between the elements, never at the ends. An empty sequence produces an empty string. The method is eager, as it must be.

string.Join(", ", items.Select(...)) reads inside out: the last operation comes first. items.Select(...).JoinToString(", ") reads in the order things happen.

An empty separator is concatenation, and ConcatToString says so; λ1004 points this out.

Example

var names = Sequence.Return("Ada", "Grace", "Linus");

names.JoinToString(", ");
// "Ada, Grace, Linus"

Enumerable.Range(1, 5).JoinToString('-');
// "1-2-3-4-5"

var csvLine = record.Fields.Select(Escape).JoinToString(',');

ConcatToString

string.Concat as an extension method: all elements converted with ToString and glued together with nothing in between.

string ConcatToString<TSource>(this IEnumerable<TSource> source)

The typical input is a sequence of char or of string, for example the result of a character-level transformation that should become a string again.

Example

"hello".Reverse().ConcatToString();
// "olleh"

"Hello World".Where(char.IsLetter).ConcatToString();
// "HelloWorld"

var html = elements.Select(RenderElement).ConcatToString();

Sequence

Turns a sequence of monadic values inside out: an IEnumerable<Option<T>> becomes an Option<IReadOnlyList<T>>, and likewise for Result<T>, Either<L, R>, Reader<E, T> and Lazy<T>.

Option<IReadOnlyList<TSource>> Sequence<TSource>(this IEnumerable<Option<TSource>> source)
Result<IReadOnlyList<TSource>> Sequence<TSource>(this IEnumerable<Result<TSource>> source)
Either<TLeft, IReadOnlyList<TSource>> Sequence<TLeft, TSource>(this IEnumerable<Either<TLeft, TSource>> source)
Reader<TEnvironment, IEnumerable<TSource>> Sequence<TEnvironment, TSource>(this IEnumerable<Reader<TEnvironment, TSource>> sequence)
Lazy<IEnumerable<TSource>> Sequence<TSource>(this IEnumerable<Lazy<TSource>> sequence)

For the three failure monads the semantics are "all or nothing": the result is Some, Ok or Right with the list of all inner values if every element succeeded, and the first None, Error or Left otherwise. The input is enumerated only up to that first failure, and the method is eager.

For Reader and Lazy there is no failure, only deferral: the result is a reader that, given an environment, runs every inner reader, or a lazy value that evaluates every inner lazy. These two are lazy themselves.

Sequence differs from WhereSelect and Partition in what a failure means. WhereSelect drops the failed elements and keeps going. Partition keeps both the failures and the successes. Sequence says that one failure fails the whole thing.

The name comes from Haskell, where sequence is the general operation of swapping two type constructors. The inverse direction, swapping a monad with a sequence inside it, lives on the monad types themselves; see the Result chapter.

Example

var allParsed = Sequence.Return("1", "2", "3").Select(ParseExtensions.ParseInt32OrNone).Sequence();
// Some([1, 2, 3])

var oneBad = Sequence.Return("1", "x", "3").Select(ParseExtensions.ParseInt32OrNone).Sequence();
// None

Select followed by Sequence is so common that it has its own name, Traverse.

Traverse

Select with a monadic selector, followed by Sequence: apply a function that may fail to every element, and get either all the results or the first failure.

Option<IReadOnlyList<TItem>> Traverse<TSource, TItem>(this IEnumerable<TSource> source, Func<TSource, Option<TItem>> selector)
Result<IReadOnlyList<TValidResult>> Traverse<TSource, TValidResult>(this IEnumerable<TSource> source, Func<TSource, Result<TValidResult>> selector)
Either<TLeft, IReadOnlyList<TRight>> Traverse<TSource, TLeft, TRight>(this IEnumerable<TSource> source, Func<TSource, Either<TLeft, TRight>> selector)
Reader<TEnvironment, IEnumerable<TResult>> Traverse<TSource, TEnvironment, TResult>(this IEnumerable<TSource> source, Func<TSource, Reader<TEnvironment, TResult>> selector)
Lazy<IEnumerable<T>> Traverse<TSource, T>(this IEnumerable<TSource> source, Func<TSource, Lazy<T>> selector)
A marble diagram showing the Traverse operation

Everything said about Sequence applies: all or nothing for Option, Result and Either, with enumeration stopping at the first failure; deferral for Reader and Lazy. Traverse just saves the intermediate sequence of monads.

This is the operation to reach for when a validation or lookup must succeed for every element before anything is done with the results:

Example

Option<IReadOnlyList<int>> ParseAll(IEnumerable<string> input)
    => input.Traverse(ParseExtensions.ParseInt32OrNone);

ParseAll(Sequence.Return("1", "2", "3"));   // Some([1, 2, 3])
ParseAll(Sequence.Return("1", "x", "3"));   // None
Result<IReadOnlyList<Order>> PlaceAll(IEnumerable<OrderRequest> requests)
    => requests.Traverse(PlaceOrder);   // the first Error wins; later requests are not attempted

Either<ValidationError, IReadOnlyList<Record>> ValidateAll(IEnumerable<string> lines)
    => lines.Traverse(ValidateLine);