Introduction

Funcky is a functional programming library for C#. It gives you an Option<T> type for values that may be absent, Result<T> and Either<L, R> for computations that may fail, a large set of extensions for IEnumerable<T> and string, and a handful of small functional helpers. All of it is built on the parts of C# you already use: LINQ, lambdas, and the type system.

Who this book is for

You are a C# developer. You write LINQ queries without thinking about it, you know what Select, Where and SelectMany do, and you are comfortable with lambdas. You have heard of functional programming, maybe of monads, but you have never worked in a functional language and the terminology sounds more intimidating than it should.

This book takes that starting point seriously. It does not assume you know what a functor is. Where a functional concept matters, the book explains it by pointing at something you already do in LINQ. You will find that you have been using monads for years; you just called them IEnumerable<T>.

The one idea behind Funcky

Funcky started from a blog post by Mark Seemann, How to get the value out of the monad. His observation was this: most Maybe or Option types come with a way to ask "is there a value?" and a way to pull the value out. Programmers then use those two operations exclusively, and the type becomes a slightly more verbose null.

Unfortunately, Maybe implementations often come with an API that enables you to ask a Maybe object if it's populated or empty, and a way to extract the value from the Maybe container. This misleads many programmers into thinking that the proper way to use the Maybe type is to first check if the value is present, and then extract it.

Funcky takes the consequence. There is no Value property, no HasValue, no GetOrThrow, and no IsSome. This is not an oversight, and it is not going to be added. Other libraries offer these members; Funcky deliberately does not.

If you come from Nullable<T> this will feel restrictive at first. You will hold an Option<int> and wonder how to get the int out of it. That moment is the point of the library. The answer is almost never "get it out". The answer is to tell the option what to do with the value if there is one, and let the option handle the case where there is none. You already do this in LINQ: you do not pull elements out of an IEnumerable<T> one by one to transform them, you call Select. Option<T> works the same way.

The book returns to this question in the Option chapter, which is organized as a ladder of answers to "how do I get the value out?", from the ones you should reach for first to the ones that exist for interop with imperative code.

Why bother

Code written in this style has fewer places where it can go wrong. A missing value cannot be forgotten, because the compiler will not let you use an Option<int> where an int is expected. A failed computation cannot be ignored, because a Result<T> has to be unwrapped before its value is usable. Side effects become visible, because they have to be placed explicitly rather than happening wherever a null check was missed.

None of this requires you to abandon the C# you know. Funcky's types support LINQ query syntax, work with pattern matching, and slot into existing code one method at a time. The case study on replacing if null shows a typical migration in four small steps.

How to read this book

  • If you want to understand the mindset first, continue with Functional Programming.
  • If you want to start using the library, go to the Option Monad. It is the type you will use most, and everything else follows the same pattern.
  • The chapters on IEnumerable and string extensions are reference material. Skim them once so you know what exists.
  • The analyzer rules are worth a look even if you never read them in full. The Funcky.Analyzers package flags code that reaches for the general tool when a specific one exists, and the explanation on each rule page doubles as a short lesson in idiomatic usage.

Migrating from 2.x to 3.0

  1. Update to the latest 2.x version of Funcky (2.7.1) and fix all warnings.
  2. Update to Funcky 3.0.
  3. Run dotnet format analyzers. This will migrate Option<T>.None() to Option<T>.None for you.
    This command sometimes fails while loading your project(s). --no-restore might help with that.
  4. Build and fix other compilation failures.
  5. You might need to re-run dotnet format analyzers after fixing other errors.
  6. Important: Check if you're using System.Text.Json to serialize Options. This will no longer work automatically. You need to add the OptionJsonConverter yourself.

Functional Programming

Functional programming is a style, not a language feature. C# has had everything it needs for the style since lambdas and LINQ arrived, and most C# developers already write functional code every day without calling it that. This chapter names the handful of ideas behind the style and shows where each one surfaces in Funcky. It is deliberately short on theory. Every section ends by pointing at the part of the library that puts the idea to work.

Pure functions and no side effects

A function is pure when its result depends only on its arguments, and calling it changes nothing outside of it. It does not write to a field, a database, the console, or the clock. Math.Max is pure. Console.WriteLine is not, and neither is a method that reads DateTime.Now.

Pure functions are easy to reason about because they can be understood in isolation. You can call them in any order, call them twice, or not at all, and nothing else in the program notices. That property is what makes LINQ work: a query is only a description, and the lambdas inside it run whenever, and however often, the enumeration decides. A side effect inside a lambda breaks that contract in ways that are hard to see:

var count = 0;
var numbered = items.Select(item => (Number: ++count, Item: item));

numbered.First();     // count is now 1
numbered.ToList();    // count continues at 2, the first element is numbered 2

Funcky marks its pure methods with [Pure] from System.Diagnostics.Contracts, and the whole library is written so that evaluating an expression has no effect beyond producing its value. Where a side effect is unavoidable, Funcky gives it a name and a place in the pipeline instead of hiding it in a lambda: Inspect, ForEach and Materialize make the point where something happens visible in the code.

Referential transparency

An expression is referentially transparent when it can be replaced by its value without changing the program. 2 + 3 can be replaced by 5 anywhere. random.Next() cannot, because the next call returns something else.

This is the property that makes refactoring safe. If every expression is referentially transparent, you can extract any sub-expression into a variable or a method, inline any variable back, and reorder independent statements, all without reading the surrounding code. Pure functions give you referential transparency for free, and side effects take it away.

It is also the reason Option<T> has no Value property. A Value getter that throws when there is nothing inside is not a function of its argument alone; it depends on a check that happened, or did not happen, somewhere else. The Option chapter explains what to use instead.

Follow the types

In a functional style the signature of a function tells you what it does, and the compiler holds you to it. Compare these two declarations:

Customer FindCustomer(string id);
Option<Customer> FindCustomer(string id);

The first one might return null, or throw, or return an empty placeholder. You have to read the body, or the documentation, or get bitten. The second one says in the signature that there might be no customer, and the compiler will not let you use the result as a Customer until you have said what happens when there is none.

The same applies to failure. A method returning Result<T> says that it can fail and carries the exception with it. A method returning Either<L, R> says it can fail and tells you the exact set of ways it can. Every piece of information that was in a comment or a convention is now in the type, where tools can see it.

Funcky's …OrNone methods apply this idea to the BCL. int.Parse throws, int.TryParse needs an out parameter, and both hide the possibility of failure from the signature. ParseInt32OrNone returns an Option<int> and is honest about it. The TryVerb-pattern chapter lists all of them.

Higher-order functions

A higher-order function takes a function as an argument or returns one. You use them every time you pass a lambda to Where or Select. There is nothing more to the concept, but once you see functions as values, a few things become natural that are awkward otherwise.

A function can be stored, named, and reused:

Func<Order, bool> isOpen = order => order.Status == OrderStatus.Open;

var open = orders.Where(isOpen);
var anyOpen = orders.Any(isOpen);

A function can be adapted. Flip swaps the arguments of a two-argument function, and Curry turns a function of several arguments into a chain of functions of one argument each, so that some arguments can be supplied now and the rest later.

And a function can be wrapped in behaviour. Retry takes an operation and a policy and returns the result of running the operation until it succeeds. The operation does not know it is being retried; the retry logic does not know what it is retrying.

Composition

Small functions are easy to write and test. Composition is how they become a program. f.Compose(g) builds a new function that runs g and then f, without running either yet:

Func<string, string> trim = s => s.Trim();
Func<string, Option<int>> parse = ParseExtensions.ParseInt32OrNone;

var parseTrimmed = parse.Compose(trim);

Composition is the reason the pieces of Funcky are small. A Where, a Select and a FirstOrNone are each trivial on their own, and a pipeline of them is a non-trivial program that needed no new code. LINQ composes sequence operations for you, Compose composes plain functions, and All, Any and Not compose predicates so that Where can be given a condition built from named parts.

Composition is also what makes a pipeline readable. A chain of named steps reads top to bottom in the order things happen. Nested calls read inside out, and a method with twelve local variables reads in whatever order the author happened to think of them.

Monads, briefly

The word scares people, so here is all of it. A monad is a type that wraps a value and offers two operations: Select, which applies a function to the value inside and keeps the wrapper, and SelectMany, which applies a function that itself returns the wrapper and flattens the two layers into one.

IEnumerable<T> is a monad. You have called Select on it for years, and SelectMany whenever you had a sequence of sequences. The query syntax from x in xs from y in f(x) select g(x, y) is nothing but a nested SelectMany.

Funcky's types follow the same shape:

TypeWrapsSelect does
IEnumerable<T>zero or more valuesapply to each element
Option<T>zero or one valueapply if there is a value
Result<T>a value or an exceptionapply if there is no error
Either<L, R>a left or a right valueapply to the right value
Reader<E, T>a value that needs an environmentapply once the environment is given
Lazy<T>a value computed on first useapply, still on first use

Because the shape is the same, the way of working is the same. You do not take the value out, transform it, and put it back. You describe the transformation with Select or a query, and the type decides whether, when, and how often it runs. If you are comfortable doing this with IEnumerable<T>, you are comfortable doing it with Option<T>; the Option chapter only has to show you the methods.

Railway oriented programming

Scott Wlaschin coined the term Railway Oriented Programming for a way of picturing a pipeline of steps that can fail. Think of two parallel tracks. Every value travels on the success track. Each step is a function that takes a value from the success track and either passes a new one on, or switches the train to the failure track. Once on the failure track, the value bypasses every remaining step and arrives at the end unchanged.

success ──▶ [validate] ──▶ [find customer] ──▶ [reserve stock] ──▶ [create order] ──▶ Ok(order)
                │                 │                   │
failure         └─────────────────┴───────────────────┴──────────────────────────────▶ Error(why)

In Funcky the two-track value is a Result<T>, or an Either<L, R> when the failure is not an exception. A step is any function returning one of those, and SelectMany is the switch that connects steps: it runs the next step for an Ok and skips it for an Error. Query syntax hides even that:

Result<Order> PlaceOrder(OrderRequest request)
    => from valid in Validate(request)
       from customer in FindCustomer(valid.CustomerId)
       from reservation in ReserveStock(valid.ProductId, valid.Quantity)
       select CreateOrder(customer, reservation);

Each from is a step. If FindCustomer returns an Error, neither ReserveStock nor CreateOrder runs, and PlaceOrder returns that error. There is no if, no try, no early return, and no flag variable, yet the failure handling is complete, and the happy path reads as if failure did not exist.

The picture also explains the rest of the API. Select is a step that cannot fail, so it only touches the success track. OrElse is a switch in the other direction, moving a failure back onto the success track with a fallback. Inspect and InspectError are observation points on one track each. Match is the end of the line, where the two tracks merge back into a plain value and the caller decides what to do with each outcome:

PlaceOrder(request).Match(
    ok: order => Results.Created($"/orders/{order.Id}", order),
    error: exception => Results.BadRequest(exception.Message));

The one discipline the style asks for is to stay on the tracks until the end. Unwrapping a Result<T> in the middle of the pipeline, handling it, and wrapping it again is what λ1005 through λ1008 warn about. The analyzers point at the operation that does the same thing without leaving the railway.

Case Studies

The reference chapters explain one function at a time. A case study does the opposite: it takes a small but complete task and solves it end to end, so you can see how the pieces of Funcky fit together, which one to reach for at which point, and what the resulting code looks like next to the conventional version.

Each study is self-contained and comes with the full program, which compiles against the current release.

Case studyTaskFeatures
Simplify if null by using an OptionRefactor a chain of null checks step by stepOption, FromNullable, Select, AndThen, GetOrElse
Formatting a calendar pageLay out a yearly calendar as a streaming pipelineSequence.Successors, AdjacentGroupBy, Chunk, Transpose, JoinToString, Curry
Importing a CSV fileParse and validate records, report or reject bad linesParseOrNone, Either, LINQ over monads, Partition, Sequence, WithIndex, MaxByOrNone, Pairwise, InclusiveScan

If you have a task that you solved with Funcky and that shows an aspect none of these cover, a new case study is a welcome contribution.

Simplify if null by using an Option

We start off with the following code: (Note that some types have been omitted for brevity)

#nullable enable

using System;

public class Example
{
    public VersionEnvironment? GetCurrentVersionEnvironment(PackageName packageName)
    {
        var currentVersion = ReadCurrentVersion(packageName);

        if (currentVersion is null)
        {
            return null;
        }

        var versionPath = GetVersionPath(packageName, currentVersion);
        return new VersionEnvironment(currentVersion, versionPath);
    }

    public PackageVersion? ReadCurrentVersion(PackageName name) => null; // Real implementation omitted

    public string GetVersionPath(PackageName name, PackageVersion version) => null!; // Real implementation omitted
}

The function GetCurrentVersionEnvironment doesn't do much, but it's not pleasant to look at, because of that early return condition.

Step Ⅰ

We start off by changing the return type of ReadCurrentVersion from PackageVersion? to an Option:

public Option<PackageVersion> ReadCurrentVersion(PackageName name) => Option.None<PackageVersion>(); // Real implementation omitted

Step Ⅱ

This immediately breaks the GetCurrentVersionEnvironment, because our null check no longer makes sense. Instead of checking for null and returning early, we can use Select on the Option to project its value.

public Option<VersionEnvironment> GetCurrentVersionEnvironment(PackageName packageName)
{
    return ReadCurrentVersion(packageName)
        .Select(currentVersion => {
            var versionPath = GetVersionPath(packageName, currentVersion);
            return new VersionEnvironment(currentVersion, versionPath);
        });
}

Step Ⅲ

This is already much simpler, since we've got rid of that explicit null check.

There's still room for simplification, since our projection takes up two lines and we ideally only want a single expression in our projection.

We can achieve this by translating our expression to query syntax:

public Option<VersionEnvironment> GetCurrentVersionEnvironment(PackageName packageName)
{
    return from currentVersion in ReadCurrentVersion(packageName)
           let versionPath = GetVersionPath(packageName, currentVersion)
           select new VersionEnvironment(currentVersion, versionPath);
}

Step Ⅳ

Since our method now consists of only one expression, we can make it an expression body:

public Option<VersionEnvironment> GetCurrentVersionEnvironment(PackageName packageName)
    => return from currentVersion in ReadCurrentVersion(packageName)
              let versionPath = GetVersionPath(packageName, currentVersion)
              select new VersionEnvironment(currentVersion, versionPath);

Formatting a calendar page

This case study is taken from Component programming with ranges by By H. S. Teoh written for D.

We shall use as example the classic task of laying out a yearly calendar on the console, such that given a particular year, the program will print out a number of lines that displays the 12 months in a nice grid layout, with numbers indicating each day within the month. Something like this:

       January              February                March        
                 1  2      1  2  3  4  5  6         1  2  3  4  5
  3  4  5  6  7  8  9   7  8  9 10 11 12 13   6  7  8  9 10 11 12
 10 11 12 13 14 15 16  14 15 16 17 18 19 20  13 14 15 16 17 18 19
 17 18 19 20 21 22 23  21 22 23 24 25 26 27  20 21 22 23 24 25 26
 24 25 26 27 28 29 30  28 29                 27 28 29 30 31
 31

         April                  May                  June  
                 1  2   1  2  3  4  5  6  7            1  2  3  4
  3  4  5  6  7  8  9   8  9 10 11 12 13 14   5  6  7  8  9 10 11
 10 11 12 13 14 15 16  15 16 17 18 19 20 21  12 13 14 15 16 17 18
 17 18 19 20 21 22 23  22 23 24 25 26 27 28  19 20 21 22 23 24 25
 24 25 26 27 28 29 30  29 30 31              26 27 28 29 30

         July                 August               September  
                 1  2      1  2  3  4  5  6               1  2  3
  3  4  5  6  7  8  9   7  8  9 10 11 12 13   4  5  6  7  8  9 10
 10 11 12 13 14 15 16  14 15 16 17 18 19 20  11 12 13 14 15 16 17
 17 18 19 20 21 22 23  21 22 23 24 25 26 27  18 19 20 21 22 23 24
 24 25 26 27 28 29 30  28 29 30 31           25 26 27 28 29 30
 31

        October              November              December  
                    1         1  2  3  4  5               1  2  3
  2  3  4  5  6  7  8   6  7  8  9 10 11 12   4  5  6  7  8  9 10
  9 10 11 12 13 14 15  13 14 15 16 17 18 19  11 12 13 14 15 16 17
 16 17 18 19 20 21 22  20 21 22 23 24 25 26  18 19 20 21 22 23 24
 23 24 25 26 27 28 29  27 28 29 30           25 26 27 28 29 30 31
 30 31

While intuitively straightforward, this task has many points of complexity.

Although generating all dates in a year is trivial, the order in which they must be processed is far from obvious. Since we're writing to the console, we're limited to outputting one line at a time; we can't draw one cell of the grid and then go back up a few lines, move a few columns over, and draw the next cell in the grid. We have to somehow print the first lines of all cells in the top row, followed by the second lines, then the third lines, etc., and repeat this process for each row in the grid. Of course, we could create an internal screen buffer that we can write to in arbitrary order, and then output this buffer line-by-line at the end, but this approach is not as elegant because it requires a much bigger memory footprint than is really necessary.

In any case, as a result of this mismatch between the structure of the calendar and the structure of the output, the order in which the days in a month must be processed is not the natural, chronological order. We have to assemble the dates for the first weeks in each of the first 3 months, say, if we are putting 3 months per row in the grid, print those out, then assemble the dates for the second weeks in each month, print those out, etc.. Furthermore, within the rows representing each week, some days may be missing, depending on where the boundaries of adjacent months fall; these missing days must be filled out in the following month's first week before the first full week in the month is printed. It is not that simple to figure out where a week starts and ends, and how many rows are needed per month. Then if some months have more full weeks than others, they may occupy less vertical space than other months on the same row in the grid; so we need to insert blank spaces into these shorter months in order for the grid cells to line up vertically in the output.

With this level of complexity, writing our calendar program using the traditional ad hoc way of resolving structure conflicts will certainly result in very complex, hard-to-understand, and bug-prone code. There would not be much hope of getting any reusable pieces out of it.

Nonetheless the end result will look pretty simple, and it will be completely streamable.

Create the date-range

There are obviously many ways to achieve this, you could write your own Implementation of IEnumerable<T> with a hand written IEnumerator<T> or you could simply write a function and take advantage of yield to create the iterator.

We want to avoid to write such a function and take advantage of the numerous generators available in Funcky.

Sequence.Successors creates an infinite sequence of days, starting with January first of the given year. We take all the values in the same year, so this sequence should yield all the days of a year.

return Sequence.Successors(JanuaryFirst(year), NextDay)
    .TakeWhile(IsSameYear(year));

Most of these helper function are straight forward, but IsSameYear might be a bit special if you haven't worked with curried functions before.

The function IsSameYear takes one parameter and returns a function which takes another parameter, this is also called the curried form of a function, there is also the Functional.Curry and Functional.Uncurry functions which can transform between both forms without the need to write them both. IsSameYear(2000) returns a function which always returns true if the Date is from the year 2000. That way of using functions might come in handy a lot more often than you think.

private static DateOnly NextDay(DateOnly day)
    => day.AddDays(1);

private static DateOnly JanuaryFirst(int year)
    => new(year: year, month: 1, day: 1);

private static Func<DateOnly, bool> IsSameYear(int year)
    => day
        => day.Year == year;

This makes the main body of the code semantic very easy to understand. All the helper functions are trivially to understand on its own too.

Group this into months

LINQ offers you the GroupBy function which we could use easily in this case. This way we would have 12 groups, in each group we would have all the days of one month of the given year.

return Sequence.Successors(JanuaryFirst(year), NextDay)
    .TakeWhile(IsSameYear(year))
    .GroupBy(d => d.Month)

There are two things we should consider here though.

1.) GroupBy is like the SQL GROUP BY and can rearrange elements, and therefore is not a lazy Extension function. 2.) GroupBy would also Group all days from a different year into the same 12 monthly buckets.

Often that is exactly what you want, but in this case if we think of an endless stream of days this is not what we need at all. The days do not need to be rearranged, all days in the same month are next to each other and if we find a second January, we would like to have a new 13th bucket.

That is why Funcky offers the extension function AdjacentGroupBy. It is a fully lazy, forward only GroupBy. Exactly what we need here.

return Sequence.Successors(JanuaryFirst(year), NextDay)
    .TakeWhile(IsSameYear(year))
    .AdjacentGroupBy(d => d.Month);

Now we have an IEnumerable<IEnumerable<DateOnly>>, where the inner IEnumerable is representing a single month.

Layout a single month

Since we have now all the days of the same month, we want to create a layout for a single month out of these days.

Currently we have a sequence of months. To transform the inner element, from a sequence of days into something else, we need to make a projection which is done with Select in C#.

return Sequence.Successors(JanuaryFirst(year), NextDay)
    .TakeWhile(IsSameYear(year))
    .AdjacentGroupBy(d => d.Month);
    .Select(LayoutMonth)

The LayoutMonth function therefore gets a sequence of all the day days in a single month, and should produce a Layout for that single Month.

This is actually pretty straight forward. What do we want? We want to format a single month, and how would a single month look like? So the result will be a sequence of strings, each string representing one line of the layout.

        April       
                1  2
 3  4  5  6  7  8  9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30

First, we decompose the problem once more: we start with one line where we print the month, then a few lines which represent a week and we also have an empty line at the end, but that is just layout sugar.

We need a few helper functions again: the most important step is grouping the days by week, and then we pass each week to a FormatWeek function.

private static IEnumerable<string> LayoutMonth(IEnumerable<DateOnly> month)
{
    yield return CenteredMonthName(month);

    foreach (var week in month.AdjacentGroupBy(GetWeekOfYear).Select(FormatWeek))
    {
        yield return week;
    }

    yield return $"{string.Empty,WidthOfAWeek}";
}

This is a fine way to do this, it is very easy to read, but the foreach is really annoying. We already have an IEnumerable<T>, we just want to add it between the first and the last line. But with yield you are often limited to very procedural constructs.

Often you can avoid that, for simple cases we have Sequence.Return, Concat and other helpers, in this case though the nicest way is probably creating an ImmutableList, because the syntax allows to combine ranges and single items elegantly.

private static IEnumerable<string> LayoutMonth(IEnumerable<DateOnly> month)
    => ImmutableList<string>.Empty
        .Add(CenteredMonthName(month))
        .AddRange(FormatWeeks(month))
        .Add(new string(' ', WidthOfAWeek));

Let's dive into our helper functions. First we take a look at the name of the month. The only noteworthy detail is the very functional mindset seen in the solution to the centering problem. It uses a pattern match to fill in the missing spaces: it is not very efficient, but easy to understand. The recursion will be very short because our lines are only 21 characters wide.

private static string CenteredMonthName(IEnumerable<DateOnly> month)
    => month
        .First()
        .ToString(MonthNameFormat)
        .Center(WidthOfAWeek);


internal static class StringExtensions
{
    public static string Center(this string text, int width)
        => (width - text.Length) switch
        {
            0 => text,
            1 => $" {text}",
            _ => Center($" {text} ", width),
        };
}

We have already seen the heart of FormatWeeks in the yield solution, but now it is a separate function. FormatWeeks again needs 2 very simple helper functions, the first one projects the week of the year, the other one will format a sequence of days.

The sequence of days can be either a complete week, or a partial week from the beginning or the end of the month. But because of the way we construct these sequences, there always is at least one element in it.

private static IEnumerable<string> FormatWeeks(IEnumerable<DateOnly> month)
    => month
        .AdjacentGroupBy(GetWeekOfYear)
        .Select(FormatWeek);

The GetWeekOfYear function is just calling the API with the correct parameters and always using the current culture and a little fiddling with DateTime (because DateOnly was introduced far too late in C# 10).

private static int GetWeekOfYear(DateOnly dateTime)
    => CultureInfo
        .CurrentCulture
        .Calendar
        .GetWeekOfYear(dateTime.ToDateTime(default), CultureInfo.CurrentCulture.DateTimeFormat.CalendarWeekRule, CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek);

We are almost done with FormatMonth, now we really format the week, each day has a width of 3 characters, we see that in FormatDay. This means each line should have the same width of 21 characters. We simply pad the week correctly with the PadWeek function.

private static string FormatWeek(IGrouping<int, DateOnly> week)
    => PadWeek(week.Select(FormatDay).ConcatToString(), week);

private static string FormatDay(DateOnly day)
    => $"{day.Day,WidthOfDay}";

We can ignore the full weeks, because they are already 21 characters long. How do we distinguish the beginning of the month from the end? The week at the end of the month must start with the first day of the week. So we pad accordingly from the left or the right.

private static string PadWeek(string formattedWeek, IGrouping<int, DateOnly> week)
    => StartsOnFirstDayOfWeek(week)
        ? $"{formattedWeek,-WidthOfAWeek}"
        : $"{formattedWeek,WidthOfAWeek}";

Now it just boils down to, what is the start of the week? It might be a surprise, that this is actually the most difficult part of this program, because the DayOfWeek enum defines Sunday as the first day of the week, which is not true for the largest part of the world, including all of europe. But CultureInfo has our back, because it tells us in the DateTimeFormat, which day of the week is the first day. However even that needs some calculation with % DaysInAWeek.

The function NthDayOfWeek gives us a 0 based index beginning with the start of the week. So we can simply check with is FirstDayOfTheWeek that we are indeed on the first day of the week. And this works independent of the given culture. Sweet.

private static bool StartsOnFirstDayOfWeek(IGrouping<int, DateOnly> week)
    => NthDayOfWeek(week.First().DayOfWeek) is FirstDayOfTheWeek;

private static int NthDayOfWeek(DayOfWeek dayOfWeek)
    => (dayOfWeek + DaysInAWeek - CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek) % DaysInAWeek;

Now we have an IEnumerable<IEnumerable<string>>, where the inner one is still representing a single month, but each line is already a completeley formatted week.

Layouting the months together.

At this point we could print a list of months, we just would need to join all the lines together and it would look like this. (shortened to 2 months)

        January      
        1  2  3  4  5
  6  7  8  9 10 11 12
 13 14 15 16 17 18 19
 20 21 22 23 24 25 26
 27 28 29 30 31      

       February      
                 1  2
  3  4  5  6  7  8  9
 10 11 12 13 14 15 16
 17 18 19 20 21 22 23
 24 25 26 27 28 29   

The next step looks simple: we would need to take line 1, 8, 15 and create the first line of the final output:

       January              February                March        

And then lines 2, 9 and 16 would be combined to:

        1  2  3  4  5                 1  2                    1

To do this lazily we use Chunk.

Chunk is supported with .NET 6, before that Funcky has nearly identical Replacement. (The return type is slightly different)

Chunk is grouping a sequence into multiple sequences of the same length. In our case we want to make the sequence of 12 months in to 4 sequences of length 3.

const MonthsPerRow = 3;

private static string CreateCalendarString(int year)
    => Sequence.Successors(JanuaryFirst(year), NextDay)
        .TakeWhile(IsSameYear(year))
        .AdjacentGroupBy(day => day.Month)
        .Select(LayoutMonth)
        .Chunk(MonthsPerRow);

That means we have now an IEnumerable<IEnumerable<IEnumerable<string>>> where in the outermost IEnumerable we group 3 months together respectively.

Why does that help us with the layout? To create the first line of our final layout, we need the first lines of each month, and then the second, and so on. So we need to group the months together.

One of these chunks now looks like this:

        Januar        |         1  2  3  4  5 |   6  7  8  9 10 11 12 |  13 14 15 16 17 18 19 |  20 21 22 23 24 25 26 |  27 28 29 30 31       |
       Februar        |                  1  2 |   3  4  5  6  7  8  9 |  10 11 12 13 14 15 16 |  17 18 19 20 21 22 23 |  24 25 26 27 28 29    |
         März         |                     1 |   2  3  4  5  6  7  8 |   9 10 11 12 13 14 15 |  16 17 18 19 20 21 22 |  23 24 25 26 27 28 29 |  30 31                |

That actually already looks a lot like what we want, but we want the months on the top not on the left.

It also looks like a matrix, and on a matrix the operation to flip the rows and the columns (on a diagonal symmetry) is called transpose.

Funcky has a lazy Transpose extension function for IEnumerable<IEnumerable<T>> as long as it is a regular matrix (same length for each subsequence).

private static string CreateCalendarString(int year)
    => Sequence.Successors(JanuaryFirst(year), NextDay)
        .TakeWhile(IsSameYear(year))
        .AdjacentGroupBy(day => day.Month)
        .Select(LayoutMonth)
        .Chunk(MonthsPerRow)
        .Select(chunk => chunk.Transpose())

After this transforamtion our chunk of 3 months looks like this:

        Januar        |        Februar        |          März
        1  2  3  4  5 |                  1  2 |                     1
  6  7  8  9 10 11 12 |   3  4  5  6  7  8  9 |   2  3  4  5  6  7  8
 13 14 15 16 17 18 19 |  10 11 12 13 14 15 16 |   9 10 11 12 13 14 15
 20 21 22 23 24 25 26 |  17 18 19 20 21 22 23 |  16 17 18 19 20 21 22
 27 28 29 30 31       |  24 25 26 27 28 29    |  23 24 25 26 27 28 29
                      |                       |  30 31

I think it is obvious that at this point, we are done. We just have to join the chunks together to have our final output.

private static string CreateCalendarString(int year)
    => Sequence.Successors(JanuaryFirst(year), NextDay)
        .TakeWhile(IsSameYear(year))
        .AdjacentGroupBy(day => day.Month)
        .Select(LayoutMonth)
        .Chunk(MonthsPerRow)
        .Select(EnumerableExtensions.Transpose)
        .Select(JoinLine)
        .SelectMany(Identity)
        .JoinToString(Environment.NewLine);

For a working solution with all the details, you should take a look at the complete code in Program.cs

Program.cs

The complete calendar program:

using System.Collections.Immutable;
using Funcky.Extensions;
using Funcky;
using Funcky.Monads;
using static System.Globalization.CultureInfo;
using static Funcky.Functional;

namespace Calendar;

internal static class StringExtensions
{
    public static string Center(this string text, int width)
        => (width - text.Length) switch
        {
            0 => text,
            1 => $" {text}",
            _ => Center($" {text} ", width),
        };
}

internal class Program
{
    private const int FirstDayOfTheWeek = 0;
    private const int MonthsPerRow = 3;
    private const int DaysInAWeek = 7;
    private const int WidthOfDay = 3;
    private const int WidthOfAWeek = WidthOfDay * DaysInAWeek;
    private const string MonthNameFormat = "MMMM";

    private static void Main(string[] args)
        => Console.WriteLine(CreateCalendarString(ExtractYear(args).GetOrElse(DateTime.Now.Year)));

    private static Option<int> ExtractYear(IEnumerable<string> args)
        => args.FirstOrNone()
            .AndThen(ParseExtensions.ParseInt32OrNone);

    private static string CreateCalendarString(int year)
        => Sequence.Successors(JanuaryFirst(year), NextDay)
            .TakeWhile(IsSameYear(year))
            .AdjacentGroupBy(day => day.Month)
            .Select(LayoutMonth)
            .Chunk(MonthsPerRow)
            .Select(EnumerableExtensions.Transpose)
            .Select(JoinLine)
            .SelectMany(Identity)
            .JoinToString(Environment.NewLine);

    private static DateOnly NextDay(DateOnly day)
        => day.AddDays(1);

    private static DateOnly JanuaryFirst(int year)
        => new(year: year, month: 1, day: 1);

    private static Func<DateOnly, bool> IsSameYear(int year)
        => day
            => day.Year == year;

    private static IEnumerable<string> JoinLine(IEnumerable<IEnumerable<string>> sequence)
        => sequence.Select(t => t.JoinToString(" "));

    private static IEnumerable<string> LayoutMonth(IEnumerable<DateOnly> month)
        => ImmutableList<string>.Empty
            .Add(CenteredMonthName(month))
            .AddRange(FormatWeeks(month))
            .Add(new string(' ', WidthOfAWeek));

    private static IEnumerable<string> FormatWeeks(IEnumerable<DateOnly> month)
        => month
            .AdjacentGroupBy(GetWeekOfYear)
            .Select(FormatWeek);

    private static string FormatWeek(IGrouping<int, DateOnly> week)
        => PadWeek(week.Select(FormatDay).ConcatToString(), week);

    private static string FormatDay(DateOnly day)
        => $"{day.Day,WidthOfDay}";

    private static string PadWeek(string formattedWeek, IEnumerable<DateOnly> week)
        => StartsOnFirstDayOfWeek(week)
            ? $"{formattedWeek,-WidthOfAWeek}"
            : $"{formattedWeek,WidthOfAWeek}";

    private static bool StartsOnFirstDayOfWeek(IEnumerable<DateOnly> week)
        => NthDayOfWeek(week.First().DayOfWeek) is FirstDayOfTheWeek;

    private static int NthDayOfWeek(DayOfWeek dayOfWeek)
        => (dayOfWeek + DaysInAWeek - CurrentCulture.DateTimeFormat.FirstDayOfWeek) % DaysInAWeek;

    private static int GetWeekOfYear(DateOnly dateTime)
        => CurrentCulture
            .Calendar
            .GetWeekOfYear(
                dateTime.ToDateTime(default),
                CurrentCulture.DateTimeFormat.CalendarWeekRule,
                CurrentCulture.DateTimeFormat.FirstDayOfWeek);

    private static string CenteredMonthName(IEnumerable<DateOnly> month)
        => month
            .First()
            .ToString(MonthNameFormat)
            .Center(WidthOfAWeek);
}

Importing a CSV file

Reading records from a text file is one of the most common tasks there is, and one where things go wrong in the data rather than in the code: a date that does not exist, a number with a typo, a line with a field missing. This case study imports a small bank statement and shows how Funcky handles those failures as values, so the two questions "what is wrong" and "what do we do about it" get answered in separate places.

The input has a header line, then one transaction per line with a date, a description and an amount. Three of the lines are broken on purpose:

Date,Description,Amount
2026-01-03,Salary,4200.00
2026-01-05,Groceries,-83.20
2026-01-19,Rent,-1450.00
2026-01-31,Groceries,-121.70
2026-02-01,Dentist,-180
2026-02-30,Refund,35.00
2026-02-14,Concert tickets,-2x40
2026-02-17,Bike repair
2026-03-02,Salary,4200.00
2026-03-06,Groceries,-97.45

February 30th is not a date, -2x40 is not an amount, and the bike repair has no amount at all.

We want two things from the import. First, every line is either a Transaction or a description of what is wrong with it, with the line number, so the person who maintains the file can fix it. Second, the caller decides the policy: skip the bad lines and import the rest, or reject the whole file as soon as one line is broken. Both policies should use the same parser.

The complete program is listed on the Program.cs page.

Reading the lines

SplitLines from the string extensions splits the text into lines lazily, whatever the line ending. We skip the header, and because the error messages need line numbers, we attach the index of each remaining line with WithIndex. The result is a sequence of ValueWithIndex<string>, a small struct with the Value and its Index.

var lines = csv
    .SplitLines()
    .Skip(1)
    .WithIndex()
    .Select(ParseLine);

ParseLine turns one indexed line into an Either<ImportError, Transaction>. Nothing has happened yet when this statement completes, the whole pipeline is deferred until something consumes it.

Parsing one line

A line is valid when it has exactly three fields, the first one parses as a date and the third one as a decimal. The description can be anything. The result type is Either<ImportError, Transaction>, where ImportError is a plain record with the line number and a message:

record Transaction(DateOnly Date, string Description, decimal Amount);

record ImportError(int LineNumber, string Message);

Why Either rather than Result? Result<T> carries an Exception as its failure, which is the right choice when the failure comes from an API that throws. Here the failure is a domain value we construct ourselves, with no stack trace and no exception type to choose, so Either<TLeft, TRight> with our own left type fits better. The convention is that the left side is the failure and the right side the value.

The parser itself is one expression:

static Either<ImportError, Transaction> ParseLine(ValueWithIndex<string> line)
    => line.Value.Split(',') is [var date, var description, var amount]
        ? from parsedDate in date.ParseDateOnlyOrNone(InvariantCulture).ToEither(Error(line, $"'{date}' is not a date"))
          from parsedAmount in amount.ParseDecimalOrNone(NumberStyles.Number, InvariantCulture).ToEither(Error(line, $"'{amount}' is not an amount"))
          select new Transaction(parsedDate, description, parsedAmount)
        : Either<ImportError, Transaction>.Left(Error(line, "expected 3 fields"));

// The header is line 1 and was skipped, so the first indexed line is line 2.
static ImportError Error(ValueWithIndex<string> line, string message)
    => new(line.Index + 2, message);

Three things are happening here.

The list pattern [var date, var description, var amount] checks the field count and names the fields in one go. If it does not match, the line is a Left right away.

ParseDateOnlyOrNone and ParseDecimalOrNone are the TryVerb-pattern wrappers from the parse extensions. They return an Option instead of a bool plus an out parameter, so they can be used inside an expression. An Option says "there is no date", but not why, so ToEither upgrades it to an Either by supplying the error for the None case. The error carries the offending text, which is the single most useful thing to put into a message of this kind.

The two from clauses are LINQ query syntax over Either. Every monad in Funcky implements Select and SelectMany, which is all the compiler needs to allow from … in … select. The query reads like the happy path: take the date, take the amount, build the transaction. If any from source is a Left, the remaining clauses are skipped and that Left is the result of the whole expression. This is the same short-circuiting the Option chapter describes, now with an error attached.

Note that no step in ParseLine can throw. A broken line is a value like any other and the pipeline keeps flowing.

Two import policies

With the parser in place, the policy decision is a single call on the sequence of Either values.

The lenient policy keeps everything that parsed and reports the rest. Partition on an IEnumerable<Either<TLeft, TRight>> evaluates the sequence once and splits it into the left values and the right values, which deconstruct into two lists:

var (errors, transactions) = lines.Partition();
foreach (var error in errors)
{
    Console.WriteLine($"line {error.LineNumber}: {error.Message}");
}

Console.WriteLine(Report(transactions));

The strict policy rejects the file at the first broken line. Sequence turns an IEnumerable<Either<TLeft, TRight>> inside out into an Either<TLeft, IReadOnlyList<TRight>>: either all lines were right and you get the list, or you get the first left. It stops enumerating at that line, so a broken line early in a large file costs nothing further. Match then produces the message or the report:

Console.WriteLine(lines.Sequence().Match(
    left: error => $"import rejected, line {error.LineNumber}: {error.Message}",
    right: Report));

Both policies consume the same lazily built lines. The parser does not know which policy will be applied, and the policy does not know how a line is parsed. The two overloads exist for Result<T> as well, with Error and Ok partitions, so a parser that wraps a throwing API fits the same two calls.

The report

The imported transactions feed a small report. Each line of it is one Funcky operation on the list, and the Report function only assembles them:

static string Report(IReadOnlyList<Transaction> transactions)
    => Sequence
        .Return(
            $"{transactions.Count} transactions imported",
            MonthlyTotals(transactions),
            LargestIncome(transactions),
            LongestGap(transactions),
            $"final balance: {Balance(transactions):F2}")
        .JoinToString(Environment.NewLine);

Sequence.Return builds a sequence from its arguments and JoinToString is the string.Join you can put at the end of a pipeline.

Monthly totals use AdjacentGroupBy, the same forward-only grouping as in the calendar. A statement is ordered by date, so transactions of the same month are adjacent and there is no need to sort or to build a dictionary. The overload with a result selector receives the key and the group's elements and projects each group directly into its output line:

static string MonthlyTotals(IEnumerable<Transaction> transactions)
    => transactions
        .AdjacentGroupBy(
            transaction => (transaction.Date.Year, transaction.Date.Month),
            (month, items) => $"  {month.Year}-{month.Month:00}: {items.Sum(transaction => transaction.Amount),9:F2}")
        .Prepend("monthly totals:")
        .JoinToString(Environment.NewLine);

The largest income is the element with the largest amount. MaxByOrNone returns None for an empty list instead of throwing, and Match turns both cases into a line of text:

static string LargestIncome(IEnumerable<Transaction> transactions)
    => transactions
        .MaxByOrNone(transaction => transaction.Amount)
        .Match(
            none: "largest income: none",
            some: transaction => $"largest income: {transaction.Description} ({transaction.Amount:F2})");

The longest gap between two transactions needs each element together with its predecessor. Pairwise yields exactly that, and with a result selector it computes the distance in days right away. MaxOrNone handles the case of fewer than two transactions, where there is no pair at all:

static string LongestGap(IEnumerable<Transaction> transactions)
    => transactions
        .Pairwise((earlier, later) => later.Date.DayNumber - earlier.Date.DayNumber)
        .MaxOrNone()
        .Match(
            none: "longest gap: fewer than two transactions",
            some: days => $"longest gap between two transactions: {days} days");

The final balance is a running sum. InclusiveScan is Aggregate that yields every intermediate value, which is what you would need to print a balance column. Here we only keep the last one, and LastOrNone with GetOrElse makes an empty import a balance of zero rather than an exception:

static decimal Balance(IEnumerable<Transaction> transactions)
    => transactions
        .InclusiveScan(0m, (balance, transaction) => balance + transaction.Amount)
        .LastOrNone()
        .GetOrElse(0m);

Output

Running the program prints:

== Lenient import: skip the bad lines ==
line 7: '2026-02-30' is not a date
line 8: '-2x40' is not an amount
line 9: expected 3 fields
7 transactions imported
monthly totals:
  2026-01:   2545.10
  2026-02:   -180.00
  2026-03:   4102.55
largest income: Salary (4200.00)
longest gap between two transactions: 29 days
final balance: 6467.65

== Strict import: all or nothing ==
import rejected, line 7: '2026-02-30' is not a date

What this shows

  • Parsing with …OrNone instead of TryParse keeps the parser an expression.
  • ToEither is the bridge from "no value" to "no value, and here is why".
  • LINQ query syntax works over Either, Option and Result, and short-circuits on the failure case.
  • Partition and Sequence are the two policies for a sequence of fallible values: collect everything, or fail at the first problem. The parser does not change between them.
  • AdjacentGroupBy, Pairwise and InclusiveScan express grouping, neighbour comparison and running totals without index arithmetic.
  • Every …OrNone in the report makes the empty case explicit, so an empty file produces a report rather than an InvalidOperationException from Max or Last.

Program.cs

The complete import program:

using System.Globalization;
using Funcky;
using Funcky.Extensions;
using Funcky.Monads;
using static System.Globalization.CultureInfo;

CurrentCulture = InvariantCulture;

const string csv = """
    Date,Description,Amount
    2026-01-03,Salary,4200.00
    2026-01-05,Groceries,-83.20
    2026-01-19,Rent,-1450.00
    2026-01-31,Groceries,-121.70
    2026-02-01,Dentist,-180
    2026-02-30,Refund,35.00
    2026-02-14,Concert tickets,-2x40
    2026-02-17,Bike repair
    2026-03-02,Salary,4200.00
    2026-03-06,Groceries,-97.45
    """;

var lines = csv
    .SplitLines()
    .Skip(1)
    .WithIndex()
    .Select(ParseLine);

Console.WriteLine("== Lenient import: skip the bad lines ==");
var (errors, transactions) = lines.Partition();
foreach (var error in errors)
{
    Console.WriteLine($"line {error.LineNumber}: {error.Message}");
}

Console.WriteLine(Report(transactions));

Console.WriteLine();
Console.WriteLine("== Strict import: all or nothing ==");
Console.WriteLine(lines.Sequence().Match(
    left: error => $"import rejected, line {error.LineNumber}: {error.Message}",
    right: Report));

static Either<ImportError, Transaction> ParseLine(ValueWithIndex<string> line)
    => line.Value.Split(',') is [var date, var description, var amount]
        ? from parsedDate in date.ParseDateOnlyOrNone(InvariantCulture).ToEither(Error(line, $"'{date}' is not a date"))
          from parsedAmount in amount.ParseDecimalOrNone(NumberStyles.Number, InvariantCulture).ToEither(Error(line, $"'{amount}' is not an amount"))
          select new Transaction(parsedDate, description, parsedAmount)
        : Either<ImportError, Transaction>.Left(Error(line, "expected 3 fields"));

// The header is line 1 and was skipped, so the first indexed line is line 2.
static ImportError Error(ValueWithIndex<string> line, string message)
    => new(line.Index + 2, message);

static string Report(IReadOnlyList<Transaction> transactions)
    => Sequence
        .Return(
            $"{transactions.Count} transactions imported",
            MonthlyTotals(transactions),
            LargestIncome(transactions),
            LongestGap(transactions),
            $"final balance: {Balance(transactions):F2}")
        .JoinToString(Environment.NewLine);

static string MonthlyTotals(IEnumerable<Transaction> transactions)
    => transactions
        .AdjacentGroupBy(
            transaction => (transaction.Date.Year, transaction.Date.Month),
            (month, items) => $"  {month.Year}-{month.Month:00}: {items.Sum(transaction => transaction.Amount),9:F2}")
        .Prepend("monthly totals:")
        .JoinToString(Environment.NewLine);

static string LargestIncome(IEnumerable<Transaction> transactions)
    => transactions
        .MaxByOrNone(transaction => transaction.Amount)
        .Match(
            none: "largest income: none",
            some: transaction => $"largest income: {transaction.Description} ({transaction.Amount:F2})");

static string LongestGap(IEnumerable<Transaction> transactions)
    => transactions
        .Pairwise((earlier, later) => later.Date.DayNumber - earlier.Date.DayNumber)
        .MaxOrNone()
        .Match(
            none: "longest gap: fewer than two transactions",
            some: days => $"longest gap between two transactions: {days} days");

static decimal Balance(IEnumerable<Transaction> transactions)
    => transactions
        .InclusiveScan(0m, (balance, transaction) => balance + transaction.Amount)
        .LastOrNone()
        .GetOrElse(0m);

record Transaction(DateOnly Date, string Description, decimal Amount);

record ImportError(int LineNumber, string Message);

Option Monad

Option<T> represents a value that may be absent. It is either Some with a value of type T, or None. That is the whole type. Everything else in this chapter is about what you can do with it.

If you have used Nullable<T> or nullable reference types, the idea is familiar. The difference is in the API. Nullable<T> lets you check HasValue and then read Value. Option<T> does not. Instead, you pass the option a function describing what should happen with the value, and the option applies it only if a value exists.

Think of it as a list with at most one element

The mental model that makes Option<T> click for LINQ users: an Option<T> is a sequence with zero or one elements.

You never write this with a list:

IEnumerable<int> numbers = GetNumbers();
var doubled = new List<int>();
if (numbers.Any())
{
    foreach (var n in numbers)
    {
        doubled.Add(n * 2);
    }
}

You write numbers.Select(n => n * 2), and an empty input simply produces an empty output. Option<T> gives you the same operations with the same names and the same semantics. Select on a None is a None. Where can turn a Some into a None. SelectMany chains operations that each might produce nothing. Query syntax works.

Funcky even lets you take the model literally: option.ToEnumerable() gives you the zero-or-one-element sequence, and list patterns match on it directly:

string Describe(Option<int> option)
    => option switch
    {
        [var value] => $"Some({value})",
        [] => "None",
    };

Creating options

// Some
Option<int> some = Option.Some(42);
Option<int> alsoSome = 42;                  // implicit conversion from T
Option<int> viaReturn = Option.Return(42);  // same as Some, the name monads use

// None
Option<int> none = Option<int>.None;
Option<int> alsoNone = default;             // default is a valid None

// From a nullable
Option<string> fromNullable = Option.FromNullable(Environment.GetEnvironmentVariable("HOME"));

// From a condition
Option<int> even = (number % 2 == 0).Then(number);                // Some(number) when true, None otherwise
Option<string> name = user.HasName.Then(() => user.Name);          // the selector only runs when true
Option<IFormFile> file = request.HasFormContentType.Then(() => request.Form.Files.FirstOrNone()); // flattening

Option.FromBoolean, the previous form of this, is obsolete since 3.8.0 and will be removed in 4.0.0. The Funcky.Analyzers package offers a code fix for the obsoletion warning that rewrites it to Then.

The type parameter is constrained to notnull. An Option<string?> does not exist, because "absent" is what None is for. Option.FromNullable is the bridge: it maps null to None and anything else to Some.

Most options you will work with do not come from these constructors. They come from the many …OrNone methods Funcky adds to the BCL, which replace null returns, -1 sentinels, exceptions and Try… out-parameters with an Option<T>:

Option<int> parsed = "42".ParseInt32OrNone();
Option<string> first = names.FirstOrNone();
Option<int> index = text.IndexOfOrNone(':');
Option<User> user = usersById.GetValueOrNone(id);

See The TryVerb-pattern and String Extensions for the full lists.

How do I get the value out?

This is the question every newcomer asks, and the rest of this chapter is the answer. There is no Value property and no GetOrThrow. The methods below are ordered from the ones you should reach for first to the ones that exist only for interop with imperative code. When you find yourself further down the ladder than necessary, the built-in analyzers and the Funcky.Analyzers package will usually tell you.

1. Don't. Stay inside with Select

Most of the time you do not need the value out. You need to do something with it and are happy to get an option back. That is Select, exactly as in LINQ:

Option<int> length = "42".ParseInt32OrNone().Select(n => n * 2);

// or in query syntax
Option<int> length =
    from n in "42".ParseInt32OrNone()
    select n * 2;

If the input is None, the lambda never runs and the result is None. No check, no branch, no way to forget the empty case.

The selector has to return a value, not null: TResult is constrained to notnull. If it can return null, for example user => user.MiddleName with a string? property, the compiler reports CS8714 ("Nullability of type argument 'string?' doesn't match 'notnull' constraint") at the call. The repair is SelectMany with Option.FromNullable, which turns the null into None; the Funcky.Analyzers package offers this rewrite as a code fix for CS8714:

Option<string> middleName = user.SelectMany(u => Option.FromNullable(u.MiddleName));

2. Chain operations that might each fail with SelectMany

When the function you want to apply itself returns an option, Select would give you an Option<Option<T>>. SelectMany flattens that, again exactly as in LINQ:

Option<User> FindRequestingUser(IReadOnlyDictionary<string, string> headers)
    => from header in headers.GetValueOrNone("X-User-Id")
       from id in header.ParseInt32OrNone()
       from user in userRepository.FindOrNone(id)
       select user;

Three lookups, each of which can fail, and no if in sight. If any step yields None, the whole expression is None. This is the operation that makes Option<T> a monad, and it is the reason the type exists.

3. Filter with Where

Where turns a Some into a None when the predicate is false:

Option<int> positive = "42".ParseInt32OrNone().Where(n => n > 0);

Again this composes with query syntax, so from n in ... where n > 0 select n reads exactly like it would over a list.

4. Provide a fallback with GetOrElse or OrElse

Eventually a value has to leave the option, usually at the boundary to code that does not use Option<T>. The safe way to do that is to say what happens in the None case.

GetOrElse produces a plain T by supplying a fallback value:

int port = configuration.GetValueOrNone("port")
    .SelectMany(ParseExtensions.ParseInt32OrNone)
    .GetOrElse(8080);

// Lazy variant if the fallback is expensive to compute
string name = user.Select(u => u.Name).GetOrElse(() => LookupDefaultName());

OrElse stays inside the option and supplies an alternative option instead, which is what you want when you have several places to look:

Option<string> setting = environmentVariable
    .OrElse(configurationFile)
    .OrElse(() => ReadFromRegistry());

5. Handle both cases with Match

Match is the most general way to leave an option. You provide a function for each case and get back whatever those functions return:

string message = "42".ParseInt32OrNone().Match(
    none: "That was not a number",
    some: n => $"You entered {n}");

Every other method in this chapter could be written with Match, and that is exactly why you should treat it as the last resort rather than the first. When one branch of a Match is the identity, you wanted GetOrElse. When the some branch just wraps the value again, you wanted OrElse or SelectMany. The analyzers λ1005 to λ1008 flag these cases and offer a code fix.

Always name the arguments (none:, some:). Both are functions of a similar shape and swapping them compiles. λ1003 enforces this.

6. Perform side effects with Switch, AndThen and Inspect

Functional code tries to push side effects to the edges, but eventually something has to be printed or written.

Switch is Match for actions; it returns nothing:

"42".ParseInt32OrNone().Switch(
    none: () => Console.WriteLine("not a number"),
    some: n => Console.WriteLine($"got {n}"));

AndThen runs an action only in the Some case:

userRepository.FindOrNone(id).AndThen(user => SendWelcomeMail(user));

Inspect and InspectNone run an action and hand the option back unchanged, which is useful for logging in the middle of a chain, in the same way Inspect works on IEnumerable<T>:

Option<User> user = FindUser(id)
    .Inspect(u => logger.LogDebug("found {User}", u.Name))
    .InspectNone(() => logger.LogWarning("user {Id} not found", id));

7. Leave the world of options at the boundary

Sometimes the code on the other side speaks null or IEnumerable<T>, and you have to translate:

string? nullable = option.ToNullable();        // Some(x) -> x, None -> null
IEnumerable<int> items = option.ToEnumerable(); // Some(x) -> [x], None -> []

ToNullable is the right tool at an API boundary. Inside your own code prefer to keep the option; λ1008 will flag a Match that re-implements it.

8. TryGetValue, only where the language forces you

There is one escape hatch, TryGetValue(out var value), and the built-in analyzer λ0001 reports an error if you use it anywhere except the two places where nothing else works: a loop condition and a catch … when filter.

// A loop condition: you cannot write this with Select or Match.
var current = first;
while (current.TryGetValue(out var value))
{
    yield return value;
    current = successor(value);
}

If you find yourself wanting TryGetValue in ordinary code, go back up the ladder. There is almost always a Select, SelectMany or GetOrElse that says the same thing without the check.

Putting it together

Here is the shape of a typical function that uses options throughout. Notice that the only place a value leaves an option is the last line, and that place states what happens when there is none:

string Greeting(IReadOnlyDictionary<string, string> headers)
    => (from header in headers.GetValueOrNone("X-User-Id")
        from id in header.ParseInt32OrNone()
        from user in userRepository.FindOrNone(id)
        where user.IsActive
        select $"Welcome back, {user.Name}")
        .GetOrElse("Welcome, stranger");

Compare that with the imperative version and count the if statements it would need. Every one of them is a place where a null could slip through. Here, the compiler does not let it.

Where to go next

The TryVerb-pattern

Many operations cannot always produce a value. A string may not be a number, a key may not be in the dictionary, a queue may be empty. The BCL has grown three different conventions for saying "there is nothing here", and Option<T> replaces all of them. This page explains why, and lists every BCL member for which Funcky provides an …OrNone alternative.

Three conventions for one problem

Throwing. The oldest convention: int.Parse throws a FormatException when the input is not a number. The failure is invisible in the signature, expensive at runtime, and easy to forget to handle.

var number = int.Parse(input); // throws on "abc"

Sentinel values. IndexOf returns -1, FirstOrDefault returns null or 0, Stream.ReadByte returns -1. The failure is encoded as a value of the same type, so nothing forces the caller to check it, and for FirstOrDefault on a List<int> you cannot even tell an absent element from a present zero.

var index = text.IndexOf(':');
var key = text.Substring(0, index); // ArgumentOutOfRangeException when index is -1

The TryVerb-pattern. TryParse, TryGetValue, TryDequeue return a bool and hand the value back through an out parameter. This is the convention the BCL has settled on, and it is better than the other two: the failure is in the signature, and the compiler nudges you into checking the bool.

if (int.TryParse(input, out var number))
{
    // number is valid here
}

It is still a workaround. The method wants to return one of two things, a number or nothing, and C# had no way to express that in a single return value. So it returns two values and leaves it to a convention that the out parameter is only meaningful when the bool is true.

Why it is unnecessary with sum types

A type that holds one of several alternatives is called a sum type. Option<T> is the simplest one: Some(T) or None. Once you have it, the TryVerb-pattern is just Option<T> flattened into two variables, and all of its awkwardness comes from that flattening:

  • The correlation is a convention, not a type. Nothing stops you from reading number when TryParse returned false. Nullable annotations such as [NotNullWhen(true)] patch this for reference types but cannot express it for int. With Option<int> there is no int to read until you have handled the None case.
  • out is a statement, not an expression. You need a variable declared before the call, which means a mutable local, a separate if, and a block. You cannot use a Try method in a LINQ query, chain it, pass it as a Func, or write it as an expression-bodied member without a helper.
  • It does not compose. Parsing three numbers from three strings takes three ifs and three temporaries. Three Option<int> compose with SelectMany into one expression that is None if any of them is.
  • It does not work everywhere. Iterators and async methods cannot have out parameters, so the pattern cannot even be used consistently across the BCL's own asynchronous APIs.

Compare the two versions of the same function:

// TryVerb-pattern
bool TryGetPort(IReadOnlyDictionary<string, string> settings, out int port)
{
    port = default;
    return settings.TryGetValue("port", out var text)
        && int.TryParse(text, out port)
        && port > 0;
}

// Option<T>
Option<int> GetPort(IReadOnlyDictionary<string, string> settings)
    => settings.GetValueOrNone("port")
        .SelectMany(ParseExtensions.ParseInt32OrNone)
        .Where(port => port > 0);

The second one is shorter, has no mutable state, and is itself usable in the next SelectMany. The first one cannot be, because it has an out parameter, so every caller starts the if dance again.

The transformation from a Try method to an Option is mechanical:

Option<int> ParseInt32OrNone(this string candidate)
    => int.TryParse(candidate, out var result)
        ? result
        : Option<int>.None;

Funcky applies it once, at the library boundary, for every Try member it knows about, so your code never has to. For the Parse family this is done by a source generator that mirrors every overload of the BCL TryParse method, including the ones taking NumberStyles, IFormatProvider, ReadOnlySpan<char> and UTF-8 ReadOnlySpan<byte>. If you find a Try method in the BCL that has no …OrNone counterpart, that is a bug report we are happy to receive.

Naming

All methods follow the same convention: the verb of the BCL member, suffixed with OrNone. The subject of the Try method becomes the receiver of an extension method, so int.TryParse(text, out var n) becomes text.ParseInt32OrNone(), and dictionary.TryGetValue(key, out var v) becomes dictionary.GetValueOrNone(key). Methods that replace a sentinel keep the BCL name, so FirstOrDefault becomes FirstOrNone and IndexOf becomes IndexOfOrNone.

The type parameter of the resulting Option<T> is constrained to notnull. Where the BCL method can hand back null, for example FirstOrDefault on a sequence of nullable references, the …OrNone variant requires a non-nullable element type; use WhereNotNull first.

Parsing

All parse methods are extension methods on string (and on ReadOnlySpan<char> and ReadOnlySpan<byte> where the BCL offers it) in the static class ParseExtensions. Every overload of the BCL TryParse is mirrored, so "2A".ParseInt32OrNone(NumberStyles.HexNumber, CultureInfo.InvariantCulture) works exactly like the corresponding int.TryParse.

BCLFunckyAvailable on
byte.TryParseParseByteOrNoneall
sbyte.TryParseParseSByteOrNoneall
short.TryParseParseInt16OrNoneall
ushort.TryParseParseUInt16OrNoneall
int.TryParseParseInt32OrNoneall
uint.TryParseParseUInt32OrNoneall
long.TryParseParseInt64OrNoneall
ulong.TryParseParseUInt64OrNoneall
float.TryParseParseSingleOrNoneall
double.TryParseParseDoubleOrNoneall
decimal.TryParseParseDecimalOrNoneall
BigInteger.TryParseParseBigIntegerOrNoneall
bool.TryParseParseBooleanOrNoneall
char.TryParseParseCharOrNoneall
Enum.TryParse<TEnum>ParseEnumOrNone<TEnum>all
Enum.TryParse(Type, …)ParseEnumOrNone(Type).NET Standard 2.1+
Guid.TryParse / Guid.TryParseExactParseGuidOrNone / ParseExactGuidOrNoneall
DateTime.TryParse / TryParseExactParseDateTimeOrNone / ParseExactDateTimeOrNoneall
DateTimeOffset.TryParse / TryParseExactParseDateTimeOffsetOrNone / ParseExactDateTimeOffsetOrNoneall
TimeSpan.TryParse / TryParseExactParseTimeSpanOrNone / ParseExactTimeSpanOrNoneall
DateOnly.TryParse / TryParseExactParseDateOnlyOrNone / ParseExactDateOnlyOrNone.NET 6+
TimeOnly.TryParse / TryParseExactParseTimeOnlyOrNone / ParseExactTimeOnlyOrNone.NET 6+
Version.TryParseParseVersionOrNoneall
IPAddress.TryParseParseIPAddressOrNoneall
IPEndPoint.TryParseParseIPEndPointOrNone.NET Core 3.0+
IPNetwork.TryParseParseIPNetworkOrNone.NET 8+
AssemblyNameInfo.TryParseParseAssemblyNameInfoOrNone.NET 9+
TypeName.TryParseParseTypeNameOrNone.NET 9+
INumberBase<T>.TryParseParseNumberOrNone<TNumber>.NET 7+
IParsable<T>.TryParse / ISpanParsable<T>.TryParseParseOrNone<TParsable>.NET 7+
IUtf8SpanParsable<T>.TryParseParseOrNone<TParsable> on ReadOnlySpan<byte>.NET 8+

The generic ParseOrNone<T> and ParseNumberOrNone<T> cover any type implementing the static abstract parsing interfaces, including your own, so on .NET 7 and later text.ParseOrNone<Guid>() and text.ParseGuidOrNone() are equivalent. Both take an optional IFormatProvider; without one the current culture is used, as in the BCL. ParseNumberOrNone<T> additionally accepts a NumberStyles; without one the type's own default applies, Integer for the integer types and Float | AllowThousands for the floating-point types.

The static form: int.ParseOrNone

Since Funcky 3.8.0 every parse method also exists as a C# 14 static extension member on the type it parses, next to the TryParse it wraps. The receiver names the type, so the method name drops it: ParseInt32OrNone on string becomes ParseOrNone on int, ParseExactDateTimeOrNone becomes DateTime.ParseExactOrNone. Every overload of the string form is mirrored.

Option<int> number = int.ParseOrNone("42");
Option<int> hex = int.ParseOrNone("2A", NumberStyles.HexNumber, CultureInfo.InvariantCulture);
Option<DateTime> date = DateTime.ParseExactOrNone("2026-10-21", "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None);
Option<DayOfWeek> day = DayOfWeek.ParseOrNone("monday", ignoreCase: true);
Option<object> boxed = Enum.ParseOrNone(typeof(DayOfWeek), "Monday");
Option<MediaTypeHeaderValue> mediaType = MediaTypeHeaderValue.ParseOrNone("text/html");

On .NET 7 and later the generic forms are static extension members on the interfaces' type parameter, so they apply to any IParsable<T>, ISpanParsable<T>, IUtf8SpanParsable<T> (.NET 8+) or INumberBase<T>, including your own types. int.ParseOrNone(text) binds to the dedicated int member, Half.ParseOrNone(text) to the generic one.

Option<Half> half = Half.ParseOrNone("1.5", NumberStyles.Float, CultureInfo.InvariantCulture);
Option<Celsius> temperature = Celsius.ParseOrNone("21.5", CultureInfo.InvariantCulture); // any ISpanParsable<Celsius>

The static members are declared in one static class per parsed type: Int32ParseExtensions, DateTimeParseExtensions, EnumParseExtensions, ParsableParseExtensions, NumberParseExtensions and so on. The compiler lowers a static extension member to an ordinary static method without the receiver, so ParseOrNone(string) for int and for long could not share one class. The split only shows on a compiler older than C# 14 (before the .NET 10 SDK): there the members are plain static methods, Int32ParseExtensions.ParseOrNone(text), and the string extensions remain the convenient form.

The TryParse methods of the System.Net.Http.Headers value types are mirrored as well:

BCLFuncky
AuthenticationHeaderValue.TryParseParseAuthenticationHeaderValueOrNone
CacheControlHeaderValue.TryParseParseCacheControlHeaderValueOrNone
ContentDispositionHeaderValue.TryParseParseContentDispositionHeaderValueOrNone
ContentRangeHeaderValue.TryParseParseContentRangeHeaderValueOrNone
EntityTagHeaderValue.TryParseParseEntityTagHeaderValueOrNone
MediaTypeHeaderValue.TryParseParseMediaTypeHeaderValueOrNone
MediaTypeWithQualityHeaderValue.TryParseParseMediaTypeWithQualityHeaderValueOrNone
NameValueHeaderValue.TryParseParseNameValueHeaderValueOrNone
NameValueWithParametersHeaderValue.TryParseParseNameValueWithParametersHeaderValueOrNone
ProductHeaderValue.TryParseParseProductHeaderValueOrNone
ProductInfoHeaderValue.TryParseParseProductInfoHeaderValueOrNone
RangeConditionHeaderValue.TryParseParseRangeConditionHeaderValueOrNone
RangeHeaderValue.TryParseParseRangeHeaderValueOrNone
RetryConditionHeaderValue.TryParseParseRetryConditionHeaderValueOrNone
StringWithQualityHeaderValue.TryParseParseStringWithQualityHeaderValueOrNone
TransferCodingHeaderValue.TryParseParseTransferCodingHeaderValueOrNone
TransferCodingWithQualityHeaderValue.TryParseParseTransferCodingWithQualityHeaderValueOrNone
ViaHeaderValue.TryParseParseViaHeaderValueOrNone
WarningHeaderValue.TryParseParseWarningHeaderValueOrNone

Collections

BCLFunckyAvailable on
IDictionary<K, V>.TryGetValueGetValueOrNoneall
IReadOnlyDictionary<K, V>.TryGetValueGetValueOrNoneall
IDictionary<K, V>.Remove(key, out value)RemoveOrNone.NET Standard 2.1+
OrderedDictionary<K, V>.IndexOf (returns -1)IndexOfOrNone.NET 9+
Queue<T>.TryDequeue / TryPeekDequeueOrNone / PeekOrNoneall ¹
ConcurrentQueue<T>.TryDequeue / TryPeekDequeueOrNone / PeekOrNoneall
PriorityQueue<T, P>.TryDequeue / TryPeekDequeueOrNone / PeekOrNone, returning a (Element, Priority) tuple.NET 6+
IEnumerator<T>.MoveNext + CurrentMoveNextOrNoneall
Enumerable.TryGetNonEnumeratedCountGetNonEnumeratedCountOrNone.NET 6+
IList<T>.IndexOf (returns -1)IndexOfOrNoneall
List<T>.FindIndex / FindLastIndex (return -1)FindIndexOrNone / FindLastIndexOrNoneall
IImmutableList<T>.IndexOf / LastIndexOf (return -1)IndexOfOrNone / LastIndexOfOrNoneall

¹ Queue<T> only gained TryDequeue and TryPeek in .NET Standard 2.1. On .NET Standard 2.0 Funcky falls back to catching the InvalidOperationException that Dequeue and Peek throw on an empty queue, so the behaviour is the same on every target.

LINQ

These replace the …OrDefault family, whose sentinel is default(T) and therefore ambiguous for value types. They are available on IEnumerable<T> and IQueryable<T>, and as …OrNoneAsync on IAsyncEnumerable<T> (in Funcky itself on .NET 10, in the Funcky.Async package on older targets). See IEnumerable Extensions.

BCLFuncky
FirstOrDefaultFirstOrNone
LastOrDefaultLastOrNone
SingleOrDefaultSingleOrNone
ElementAtOrDefaultElementAtOrNone
Min / Max (return null for nullable types, throw otherwise)MinOrNone / MaxOrNone
MinBy / MaxByMinByOrNone / MaxByOrNone
Average (returns null for nullable types, throws otherwise)AverageOrNone

Strings

The IndexOf family returns -1 when nothing is found. Funcky mirrors every overload, including the ones taking a StringComparison or a start index and count. See String Extensions.

BCLFuncky
string.IndexOfIndexOfOrNone
string.IndexOfAnyIndexOfAnyOrNone
string.LastIndexOfLastIndexOfOrNone
string.LastIndexOfAnyLastIndexOfAnyOrNone

Streams and I/O

Stream uses yet another convention: properties that throw when the stream does not support the operation, with a separate CanSeek or CanTimeout flag you are expected to check first. The …OrNone variants fold the flag into the result.

BCLFunckyReturns None when
Stream.LengthGetLengthOrNoneCanSeek is false
Stream.PositionGetPositionOrNoneCanSeek is false
Stream.ReadTimeoutGetReadTimeoutOrNoneCanTimeout is false
Stream.WriteTimeoutGetWriteTimeoutOrNoneCanTimeout is false
Stream.ReadByte (returns -1)ReadByteOrNoneat end of stream

Miscellaneous

BCLFunckyAvailable on
HttpHeaders.TryGetValuesGetValuesOrNoneall
HttpHeadersNonValidated.TryGetValuesGetValuesOrNone.NET 6+
JsonSerializerOptions.TryGetTypeInfoGetTypeInfoOrNone.NET 8+

Everything else: Option.FromNullable

Many BCL members simply return null, from Environment.GetEnvironmentVariable to Type.GetMethod. There is no …OrNone variant for each of them, because one function covers them all:

Option<string> home = Option.FromNullable(Environment.GetEnvironmentVariable("HOME"));

FromNullable has overloads for nullable reference types and Nullable<T>, and is the right tool whenever a null means "absent". The reverse, ToNullable, is how an Option<T> leaves your code again at an API that expects null; see Option Monad.

Result Monad

Result<T> represents the outcome of a computation that may fail. It is either Ok with a value of type T, or Error with an Exception. Where Option<T> only tells you that something is missing, Result<T> also tells you why.

The type is deliberately tied to Exception. Funcky does not introduce its own error hierarchy; the error case carries whatever your code or the BCL already throws. This makes Result<T> the natural return type for a function that would otherwise throw, and it means an error can be turned back into a thrown exception at the boundary without losing information. When the error type should be something else, use Either<L, R>.

Think of it as Option<T> with a reason

Every method you know from Option<T> exists on Result<T> with the same name and the same meaning. The only difference is that the empty case is called error instead of none, and that it carries a value:

Option<T>Result<T>
Some(value)Ok(value)
NoneError(exception)
Match(none, some)Match(ok, error)
Switch(none, some)Switch(ok, error)
InspectNoneInspectError
ToNullableGetOrThrow

Select, SelectMany, OrElse, GetOrElse and Inspect are identical, and query syntax works the same way. The one operation missing is Where: filtering out a value would need an exception to put in the Error case, and there is no sensible default for it. Apart from that, the ladder in the Option chapter, from "stay inside with Select" to "handle both cases with Match", applies without change.

Creating results

// Ok
Result<int> ok = Result.Ok(42);
Result<int> alsoOk = 42;                    // implicit conversion from T
Result<int> viaReturn = Result.Return(42);  // same as Ok, the name monads use

// Error
Result<int> error = Result<int>.Error(new InvalidOperationException("boom"));

The type parameter is constrained to notnull, and Ok(null) throws. Unlike Option<T>, a Result<T> has no meaningful default: the struct is marked [NonDefaultable], λ1009 from the Funcky package reports an error if you try to construct one with default, and Match, Switch and everything built on them throw NotSupportedException at runtime for such a value, the same as for Either. Only Equals, GetHashCode and ToString stay total, so a defaulted value in a collection does not throw.

Result<T>.Error has one side effect worth knowing about. If the exception has not been thrown yet, it has no stack trace, and the error would be useless once it surfaces somewhere else. Error therefore captures the current stack trace on the exception when none is set. The Error(…) call site is where the stack trace points, which is almost always what you want.

From a function that may throw

Result.Try runs a function and captures its outcome, so an exception-based API becomes a Result at the boundary:

Result<int> parsed = Result.Try(() => int.Parse(text));                  // Ok(42) or Error(FormatException)
Result<string> body = await Result.TryAsync(() => client.GetStringAsync(uri));

TryAsync awaits the task, so it captures an exception thrown while producing the task as well as one the task faults with. Two kinds of exceptions are never captured by either form: those that signal a corrupted process (OutOfMemoryException, StackOverflowException, AccessViolationException, ThreadAbortException) and OperationCanceledException, because a cancelled operation has no result rather than an error result.

Chaining operations that may fail

The typical use of Result<T> is a pipeline of steps where each step can go wrong and the first failure should stop the rest:

Result<Order> PlaceOrder(string customerId, string productId, int quantity)
    => from customer in FindCustomer(customerId)
       from product in FindProduct(productId)
       from stock in ReserveStock(product, quantity)
       select new Order(customer, product, quantity);

If FindCustomer returns an Error, neither FindProduct nor ReserveStock run, and the Error is passed through unchanged with its exception intact. There is no try and no catch, and the signature of PlaceOrder tells the caller that it can fail. Compare that with the exception-based version, where the only way to know which exceptions a method throws is to read its body.

Recovering from an error

OrElse and GetOrElse have two overloads each. The one that takes a plain fallback discards the exception, the one that takes a function receives it, so you can decide based on what went wrong:

// Discards the exception: every failure is treated the same.
Result<Config> config = ReadConfig(path).OrElse(Result.Ok(Config.Default));

// Receives the exception: recover from some failures, keep the rest.
Result<Config> config = ReadConfig(path).OrElse(exception => exception switch
{
    FileNotFoundException => Result.Ok(Config.Default),
    _ => Result<Config>.Error(exception),
});

The same applies to GetOrElse, which leaves the result and gives you a T:

int retries = ReadSetting("retries").GetOrElse(3);
int retries = ReadSetting("retries").GetOrElse(exception => LogAndDefault(exception, 3));

Leaving the result at the boundary

Match and Switch

As with options, Match is the general tool and should be the last one you reach for:

IActionResult response = PlaceOrder(customerId, productId, quantity).Match(
    ok: order => Created(order),
    error: exception => BadRequest(exception.Message));

Switch is the same for actions. Always name the arguments; λ1003 enforces it, and λ1005 to λ1007 flag Match calls that should have been GetOrElse or OrElse.

GetOrThrow

Result<T> is often the right type inside your code and the wrong type at the edge, for example in a Main method, a test, or a framework callback that expects exceptions. GetOrThrow returns the value in the Ok case and rethrows the exception in the Error case:

Order order = PlaceOrder(customerId, productId, quantity).GetOrThrow();

The exception is rethrown with its original stack trace preserved, not wrapped and not reset. Together with the capture in Error, this means a failure deep in a pipeline reports the location where it was created, even though the throw happens somewhere else entirely.

Inspect and InspectError

Both run an action and return the result unchanged, which is convenient for logging in the middle of a chain:

Result<Order> order = PlaceOrder(customerId, productId, quantity)
    .Inspect(order => logger.LogInformation("placed order {Id}", order.Id))
    .InspectError(exception => logger.LogError(exception, "order failed"));

Working with many results

Sequence and Traverse

A sequence of results is rarely what you want to hand on; usually you want one result that is Ok with all the values, or the first Error. Sequence does exactly that for an IEnumerable<Result<T>>, and Traverse combines it with a Select:

Result<IReadOnlyList<Order>> orders = orderRequests
    .Traverse(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity));

If every call succeeds, the result is Ok with a list of orders. If any call fails, the result is that Error, and the remaining requests are not processed.

Partition

When you want to process everything and collect both the failures and the successes, Partition splits an IEnumerable<Result<T>> into two lists:

var (errors, orders) = orderRequests
    .Select(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity))
    .Partition();

logger.LogWarning("{Count} orders failed", errors.Count);

Partition materializes the whole sequence. It is also available with a result selector that receives both lists as separate parameters.

Combining with other monads

Traverse and Sequence also exist for swapping a Result<T> with another monad that sits inside it: Result<Option<T>> becomes Option<Result<T>>, Result<Either<L, R>> becomes Either<L, Result<R>>, and so on for Lazy<T>, Reader<E, T> and IEnumerable<T>. The same operations exist in the other direction on Option<T>, so Option<Result<T>>.Sequence() gives you a Result<Option<T>>.

Where to go next

  • Option Monad explains the common API in more depth; everything there applies here too.
  • Either Monad is the choice when the error should be something other than an Exception.

Either Monad

Either<L, R> holds a value of exactly one of two types. It is either Left with a value of type L, or Right with a value of type R. Neither side is privileged by the type itself, but the API treats Right as the success case and Left as the failure case, following the convention of other functional languages (mnemonic: "right" is right).

That makes Either<L, R> the generalisation of Result<T>: a Result<T> is an Either<Exception, T> with a few conveniences for exceptions. Reach for Either<L, R> when your failure case is not an exception, for example a domain error type, a list of validation messages, or simply a second kind of successful outcome.

public abstract record OrderError;
public sealed record CustomerNotFound(string CustomerId) : OrderError;
public sealed record OutOfStock(string ProductId, int Available) : OrderError;

Either<OrderError, Order> PlaceOrder(string customerId, string productId, int quantity);

The caller now sees from the signature not just that PlaceOrder can fail, but how, and the compiler can check that every failure is handled.

The same ladder, one more type parameter

Every operation from Option<T> and Result<T> exists on Either<L, R>, operating on the Right side. The Left value is passed through untouched, exactly like None or Error:

Result<T>Either<L, R>
Ok(value)Right(value)
Error(exception)Left(value)
Match(ok, error)Match(left, right)
Switch(ok, error)Switch(left, right)
InspectErrorInspectLeft

Select, SelectMany, OrElse, GetOrElse and Inspect work the same way, and so does query syntax. As with Result<T> there is no Where, because a filtered-out value would need a Left to replace it. The fallback overloads of OrElse and GetOrElse receive the Left value, so you can recover based on what went wrong.

Creating eithers

Either<OrderError, Order> right = Either<OrderError, Order>.Right(order);
Either<OrderError, Order> alsoRight = order;                    // implicit conversion from R
Either<OrderError, Order> viaReturn = Either<OrderError>.Return(order);

Either<OrderError, Order> left = Either<OrderError, Order>.Left(new OutOfStock(productId, available: 0));

Both type parameters are constrained to notnull. There is no implicit conversion from L, because an Either<string, string> would be ambiguous, and because constructing the failure case explicitly is a feature.

Either<L, R> has no valid default. The struct is marked [NonDefaultable], λ1009 built into the Funcky package reports an error for default(Either<…>), and any operation on such a value throws NotSupportedException at runtime. Only Equals, GetHashCode and ToString stay total, so a defaulted value in a collection or a log message does not throw.

Either<L>.Return looks odd at first. It is the monad's Return with the left type fixed and the right type inferred, and it exists so that Return can be passed as a method group where the left type is already known:

Either<OrderError, Order> fallback = either.Match(left: Recover, right: Either<OrderError>.Return);

λ1006 will point out that this particular example is either.OrElse(Recover).

Chaining

Either<OrderError, Order> PlaceOrder(string customerId, string productId, int quantity)
    => from customer in FindCustomer(customerId)
       from product in FindProduct(productId)
       from stock in ReserveStock(product, quantity)
       select new Order(customer, product, quantity);

All steps must share the same Left type. When they do not, SelectLeft maps the left side without touching the right one, which is how you lift a step with a specific error type into a pipeline with a more general one:

Either<CustomerNotFound, Customer> LookupCustomer(string customerId);

Either<OrderError, Customer> FindCustomer(string customerId)
    => LookupCustomer(customerId).SelectLeft(notFound => (OrderError)notFound);

Operations specific to Either<L, R>

SelectLeft

Select transforms the right value; SelectLeft transforms the left value. Together they let you map both sides, and Select followed by SelectLeft is the bifunctor map you may know from other libraries.

Flip

Flip swaps the sides, turning an Either<L, R> into an Either<R, L>. It is occasionally useful when a method treats the "wrong" side as the success case, and you want to continue with the LINQ operators on the other one.

LeftOrNone and RightOrNone

Both project an Either<L, R> to an Option<T> for one side, discarding the other:

Option<OrderError> error = PlaceOrder(customerId, productId, quantity).LeftOrNone();
Option<Order> order = PlaceOrder(customerId, productId, quantity).RightOrNone();

The opposite direction is Option<R>.ToEither(left), which turns a None into a Left with the given value:

Either<CustomerNotFound, Customer> LookupCustomer(string customerId)
    => customers.GetValueOrNone(customerId).ToEither(new CustomerNotFound(customerId));

This is the usual way to attach a reason to an option coming out of an …OrNone method, and it has a lazy overload for a left value that is expensive to construct. Note that the left type is inferred from the argument, so passing a CustomerNotFound yields an Either<CustomerNotFound, …>; specify the type arguments explicitly or use SelectLeft when you need the base type.

Working with many eithers

Sequence and Traverse

Traverse runs a function returning Either<L, R> over a sequence and stops at the first Left:

Either<OrderError, IReadOnlyList<Order>> orders = orderRequests
    .Traverse(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity));

Sequence is the same for a sequence that already holds eithers. Both also exist for swapping an Either<L, R> with a monad inside its right side, such as Either<L, Option<R>> to Option<Either<L, R>>.

Partition

Partition splits a sequence of eithers into all the left and all the right values, materializing the sequence:

var (errors, orders) = orderRequests
    .Select(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity))
    .Partition();

There is also an overload that takes the selector directly, so the Select above can be folded into the Partition call, and overloads that take a result selector receiving both lists.

Where to go next

  • Option Monad explains the common API in more depth.
  • Result Monad is the specialisation for Exception as the left type.

Reader Monad

Reader<TEnvironment, TResult> is a computation that needs an environment to run. It is a delegate, nothing more:

public delegate TResult Reader<in TEnvironment, out TResult>(TEnvironment environment);

A reader is a function from the environment to a result. What makes it a monad is that readers compose: you can build a bigger reader out of smaller ones with Select and SelectMany, and every one of them receives the same environment when the whole thing is finally run. The environment is typically a configuration, a set of services, or a connection, and the reader is the functional alternative to passing that object through every method signature, or to a dependency injection container.

Creating readers

Reader<TEnvironment, TResult> Reader<TEnvironment>.Return<TResult>(TResult value)       // ignores the environment
Reader<TEnvironment, TResult> Reader<TEnvironment>.FromFunc<TResult>(Func<TEnvironment, TResult> function)
Reader<TEnvironment, Unit> Reader<TEnvironment>.FromAction(Action<TEnvironment> action)

Because Reader is a delegate type, any lambda or method with the right shape converts to it, so most readers are written directly:

record Configuration(char QuoteChar);

static Reader<Configuration, string> Quote(string text)
    => config => $"{config.QuoteChar}{text}{config.QuoteChar}";

Reader<TEnvironment>.Return lifts a plain value into a reader that does not look at the environment. The split into a generic class with the environment and a generic method with the result exists so that Return can be passed as a method group where the result type is inferred.

Composing readers

Select transforms the result; SelectMany chains a reader with a function producing the next reader. Query syntax works, and the environment is threaded through invisibly:

static Reader<Configuration, string> QuoteAll(string start, string middle, string end)
    => from s in Quote(start)
       from m in Quote(middle)
       from e in Quote(end)
       select $"{s}{m}{e}";

Nothing has run yet. QuoteAll returns a reader, and the environment is supplied once, at the edge:

var reader = QuoteAll("a", "b", "c");

reader(new Configuration('"'));   // "\"a\"\"b\"\"c\""
reader(new Configuration('*'));   // "*a**b**c*"

The same reader run with two configurations gives two results. That is the point: the code that builds the computation does not know or care which environment it will see.

Readers over sequences

Sequence and Traverse on IEnumerable<T> work with readers: a sequence of readers becomes a reader of a sequence, run lazily under one environment.

static Reader<Configuration, IEnumerable<string>> QuoteEach(IEnumerable<string> texts)
    => texts.Traverse(Quote);

The other monads have Traverse and Sequence overloads for readers as well, for example Option<Reader<E, T>> to Reader<E, Option<T>>.

Side effects

FromAction wraps an Action<TEnvironment> into a Reader<TEnvironment, Unit>, so an effectful step can take part in a query. The action runs when the reader is run, not when it is created.

static Reader<Configuration, Unit> Log(string message)
    => Reader<Configuration>.FromAction(config => Console.WriteLine($"[{config.QuoteChar}] {message}"));

Lazy Monad

System.Lazy<T> is a value that is computed the first time it is asked for and remembered after that. Funcky does not add a lazy type; it adds the monad operations to the one the BCL already has, so lazy values can be combined without forcing them.

Creating lazy values

Lazy<T> Lazy.FromFunc<T>(Func<T> valueFactory)
Lazy<T> Lazy.Return<T>(T value)

FromFunc is new Lazy<T>(factory) with type inference. Return wraps a value that is already known, which is what you need at the boundary where an eager value meets a lazy computation.

Combining lazy values

Lazy<TResult> Select<T, TResult>(this Lazy<T> lazy, Func<T, TResult> selector)
Lazy<TResult> SelectMany<T, TResult>(this Lazy<T> lazy, Func<T, Lazy<TResult>> selector)
Lazy<TResult> SelectMany<T, TLazy, TResult>(this Lazy<T> lazy, Func<T, Lazy<TLazy>> selector, Func<T, TLazy, TResult> resultSelector)
Lazy<T> Flatten<T>(this Lazy<Lazy<T>> lazy)

Without these, combining two lazy values means reading .Value on both, which computes them, and wrapping the result in a new Lazy, which defeats the purpose. With them, the combination is itself lazy, and nothing is computed until someone reads the final .Value:

var config = Lazy.FromFunc(() => LoadConfiguration());         // expensive, maybe never needed
var connection = Lazy.FromFunc(() => OpenConnection());

var repository =
    from c in config
    from conn in connection
    select new Repository(c, conn);

// Nothing has been loaded or opened yet.

repository.Value;   // loads, opens, constructs; subsequent reads reuse the result

Only the lazy values that the final computation actually touches are evaluated. If selector in a SelectMany never reads its argument, the inner lazy stays unevaluated.

Lazy values over sequences

Sequence and Traverse on IEnumerable<T> turn a sequence of lazy values into a lazy sequence, and the other monads have overloads for Lazy<T> too. Note that the resulting Lazy<IEnumerable<T>> is lazy in two ways: the outer Lazy defers everything, and once forced, the inner sequence still evaluates each element's lazy only as it is enumerated.

Trimming and AOT

Lazy<T> has a constructor that creates T through its parameterless constructor by reflection, so the type parameters of these methods carry DynamicallyAccessedMembers annotations to keep that constructor alive under trimming. You do not have to do anything, but it explains the attribute in the signatures.

Functional Helpers

The static class Funcky.Functional collects small, general-purpose functions that make higher-order code read better. It is designed to be used with a static import, and Funcky's implicit usings add using static Funcky.Functional; for you, so all of them are called without a prefix. The examples in this chapter assume that.

Most of them exist to replace a lambda that says nothing with a name that says something: x => x becomes Identity, _ => {} becomes NoOperation, _ => true becomes True. The rest are the classic function combinators, Curry, Flip, Compose and Apply, and a Retry helper for operations that may fail transiently.

HelperPurpose
IdentityReturns its argument; the selector that selects nothing
NoOperationDoes nothing; the action for callbacks you don't need
True, FalseConstant predicates
All, Any, NotCombine predicates into a new predicate
Curry, UncurryConvert between multi-argument and one-argument-at-a-time functions
FlipSwap the first two parameters of a function
Compose, ThenChain two functions into one, backwards or forwards
ApplyFix some arguments of a function, leave the others open
FnGive a method group a delegate type so it can be passed on
RetryRepeat an operation until it succeeds, with a delay policy
UnitThe type with one value, for "returns nothing" in generics
ActionToUnit, UnitToActionConvert between Action and Func<…, Unit>

Curry, Uncurry, Flip and Compose also exist as extension methods on Func<…> and Action<…>, in Funcky.Extensions, so they can be chained: Fn(Math.Pow).Curry().

The NoOperation function is a more expressive way of manually creating an empty statement as a parameter to a method expecting a Action/Action<T>, supporting from 0 up to 8 generic parameters.

Example 1:

// The function we want to call:
public void DoSomething(int value, Action<int> callback)
{
  // ...
}

// How we would usually call it when we don't need the callback:
DoSomething(2, _ => {});

// How you can call it with NoOperation:
DoSomething(2, NoOperation);

NoOperation becomes especially useful when a Action<T> with many parameters is expected.

Example 2:

// The function we want to call:
public void DoSomething(int value, Action<int, string, float, AnyCustomClassThatYouWant> callback)
{
  // ...
}

// How we would usually call it when we don't need the callback (C#9):
DoSomething(2, (_, _, _, _) => {});
// Before C#9, this was even worse:
DoSomething(3, (_, __, ___, ____) => {});

// How you can call it with NoOperation:
DoSomething(2, NoOperation);

NoOperation is also useful when you want to use a expression body for a method.

Example 3:

// Abstract class:
public abstract class SomeClassWithExecuteAndHookBase
{
  public void Execute();
  
  protected abstract void PostExecutionHook();
}

// Derived class that doesn't have any use for the PostExecutionHook usually:
public class SomeClassWithExecuteAndHookDerived : SomeClassWithExecuteAndHookBase
{
  protected override void PostExecutionHook()
  {
  }
}

// Derived class that doesn't have any use for the PostExecutionHook with NoOperation:
public class SomeClassWithExecuteAndHookDerived : SomeClassWithExecuteAndHookBase
{
  protected override void PostExecutionHook() => NoOperation();
}

The Identity function is designed to replace parameter-returning lambdas, like sometimes used in LINQ.

Example 1:

// Method:
public void FunctionExpectingSelector<TIn, TOut>(Func<TIn, TOut> selector)
{
  // ...
}

// Usually:
FunctionExpectingSelector(x => x);
// Or:
FunctionExpectingSelector(item => item);

// With Identity:
FunctionExpectingSelector(Identity);

Example 2 (typical SelectMany selector):

// Usually result of a query:
IEnumerable<IEnumerable<int>> itemGroups = new[] { new[] { 1, 2, 3 }, new[] { 5, 6, 7 } };

// Goal: Get all items flattened.
// Common approach:
itemGroups.SelectMany(x => x);
// Or:
itemGroups.SelectMany(items => items);

// With Identity:
itemGroups.SelectMany(Identity);

Curry and Uncurry

Curry turns a function of several parameters into a chain of functions taking one parameter each. Uncurry is the inverse.

Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(Func<T1, T2, TResult> function)
Func<T1, Func<T2, Func<T3, TResult>>> Curry<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> function)
// … up to eight parameters, and the same for Action<…>, where the innermost function is an Action

Func<T1, T2, TResult> Uncurry<T1, T2, TResult>(Func<T1, Func<T2, TResult>> function)
// … likewise

A curried function can be applied to its arguments one at a time, and applying it to the first argument gives you a new function that remembers it. That is partial application, and it is the practical reason to curry in C#: you fix the arguments you know and pass the rest on as a smaller function.

Func<int, int, int> add = (a, b) => a + b;
var curriedAdd = Curry(add);       // Func<int, Func<int, int>>

var addFive = curriedAdd(5);       // Func<int, int>
addFive(3);                        // 8

Enumerable.Range(1, 3).Select(curriedAdd(10));   // [11, 12, 13]

The curried form is what you need when an API wants a Func<T, TResult> and you have a two-argument method plus one of the arguments at hand. Without Curry that is a lambda, x => Add(10, x), which is fine once and noisy when repeated.

Uncurry goes the other way, which is rarely needed in C# but completes the pair: Uncurry(Curry(f)) is f.

To fix an argument other than the first, or several at once, see Apply.

Fn

Method groups do not have a type of their own, so the compiler cannot infer the type parameters of Curry from one:

var pow = Curry(Math.Pow);
// error CS0411: The type arguments for method 'Functional.Curry<...>' cannot be inferred from the usage.

Fn is Identity restricted to the job of giving a method group its natural delegate type. Wrapping the method group makes the inference work:

var pow = Curry(Fn(Math.Pow));
// or, using the extension method form
var pow = Fn(Math.Pow).Curry();

var square = pow(2.0);   // wait, that is 2^x

That last line shows why Flip exists.

Flip

Swaps the first two parameters of a function.

Func<T2, T1, TResult> Flip<T1, T2, TResult>(Func<T1, T2, TResult> function)
Func<T2, T1, T3, TResult> Flip<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> function)
// … up to eight parameters, and the same for Action<…>

Only the first two parameters are swapped; any further ones keep their position.

Flip matters in combination with Curry. Partial application fixes the first argument, so if the argument you know is the second one, flip first:

// Math.Pow(x, y) is x^y. We want "square", so y is the one we know.
var pow = Fn(Math.Pow);
var square = Flip(pow).Curry()(2.0);   // x => Math.Pow(x, 2.0)

square(3.0);   // 9

The same applies to any two-argument method whose "configuration" parameter comes first and whose data parameter comes second, such as string.Join(separator, values):

Func<string, IEnumerable<string>, string> join = string.Join;
var commaSeparated = Flip(join).Curry()(items);   // separator => string.Join(separator, items)

Compose and Then

Chains two functions into one that applies the second first and then the first.

Func<TInput, TOutput> Compose<TInput, TIntermediate, TOutput>(this Func<TIntermediate, TOutput> f, Func<TInput, TIntermediate> g)
Func<TOutput> Compose<TIntermediate, TOutput>(this Func<TIntermediate, TOutput> f, Func<TIntermediate> g)
Action<TInput> Compose<TInput, TIntermediate>(this Action<TIntermediate> f, Func<TInput, TIntermediate> g)
Action Compose<TIntermediate>(this Action<TIntermediate> f, Func<TIntermediate> g)

Compose is an extension method on the outer function, and f.Compose(g) is x => f(g(x)), the same order as the mathematical f ∘ g: g runs first. Read it as "f after g".

Composition lets you build a pipeline of transformations as a value, before there is any data to run it on:

Func<string, string> trim = s => s.Trim();
Func<string, string> toLower = s => s.ToLowerInvariant();
Func<string, Option<int>> parse = ParseExtensions.ParseInt32OrNone;

var parseLoosely = parse.Compose(toLower).Compose(trim);   // trim, then lower, then parse

Sequence.Return(" 42 ", "x").WhereSelect(parseLoosely);     // [42]

Within a LINQ query you rarely need Compose, because Select(g).Select(f) composes for you. It is for the places where a function is passed around as a value: callbacks, strategies, and the selectors of other higher-order functions.

Then

Then is the same composition read forwards: f.Then(g) is x => g(f(x)), which is g.Compose(f).

Func<TInput, TOutput> Then<TInput, TIntermediate, TOutput>(this Func<TInput, TIntermediate> f, Func<TIntermediate, TOutput> next)
Func<TOutput> Then<TIntermediate, TOutput>(this Func<TIntermediate> f, Func<TIntermediate, TOutput> next)
Action<TInput> Then<TInput, TIntermediate>(this Func<TInput, TIntermediate> f, Action<TIntermediate> next)
Action Then<TIntermediate>(this Func<TIntermediate> f, Action<TIntermediate> next)

Use whichever reads better at the call site. Compose matches the mathematical notation and puts the outer function first; Then lists the steps in the order they run, like a LINQ chain, which is usually what you want for a pipeline of more than two steps:

var parseLoosely = trim.Then(toLower).Then(parse);         // the same function as above

Func<Order, Receipt> process = validate.Then(price).Then(persist);

The overloads taking an Action end the pipeline with a side effect and return an Action.

Apply

Partially applies a function: fixes some of its arguments and returns a function of the remaining ones.

// For every function of two to five parameters and every choice of which to fix, for example:
Func<T2, TResult> Apply<T1, T2, TResult>(Func<T1, T2, TResult> func, T1 p1, Unit p2)
Func<T1, TResult> Apply<T1, T2, TResult>(Func<T1, T2, TResult> func, Unit p1, T2 p2)
Func<T1, T3, TResult> Apply<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> func, Unit p1, T2 p2, Unit p3)
// … and the same as extension methods on Func<…>

Pass a value for every argument you want to fix and __ (two underscores, from Funcky.Discard) for every one you want to leave open. The result takes the open parameters in their original order. Unlike Curry, which fixes arguments from the left one at a time, Apply fixes any subset in one call:

using static Funcky.Discard;

int Linear(int m, int x, int c) => (m * x) + c;

var line = Fn(Linear);
var doubled = line.Apply(2, __, 0);        // x => Linear(2, x, 0)
var offsetBy = line.Apply(__, 10, __);     // (m, c) => Linear(m, 10, c)

doubled(21);                               // 42
offsetBy(1, 1);                            // 11

The function form, Apply(line, 2, __, 0), is the same thing; use whichever reads better. Both exist for two to five parameters with every combination of fixed and open positions, which is why the overload list in IntelliSense is long. As with Curry, a method group needs Fn so the type arguments can be inferred.

How the open positions are typed

__ is Unit.Value, and the overloads use Unit as the parameter type of an open position. That is what lets overload resolution pick the right shape from the arguments alone. The price is that a function whose parameter really is of type Unit cannot be partially applied on that parameter, because fixing it and leaving it open look the same. That case is rare enough not to matter in practice; if you hit it, wrap the function in a lambda that drops the Unit parameter first.

When to use which

You want toUse
Fix the first argument and pass the rest onCurry(f)(a)
Fix the second argument of a two-argument methodFlip(f).Curry()(b) or f.Apply(__, b)
Fix any mix of arguments in one stepf.Apply(…)
Build a one-argument-at-a-time functionCurry

Apply is the most direct of the three when the function has three or more parameters, because it does not force you to think about argument order.

Retry

Repeats an operation until it succeeds, or until a retry policy says to stop.

There are two families. The first works with an Option-returning producer, where None means "not yet":

TResult Retry<TResult>(Func<Option<TResult>> producer)
Option<TResult> Retry<TResult>(Func<Option<TResult>> producer, IRetryPolicy retryPolicy)

The second works with a producer that throws, and a predicate deciding which exceptions are worth retrying:

TResult Retry<TResult>(Func<TResult> producer, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy)
void Retry(Action action, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy)

The Option-based form

A producer that returns Option<T> is the functional way to say "this may not have worked": no exception, no bool and out parameter, just a value that is either there or not. Retry calls it again until it is.

The overload without a policy retries forever, with no delay, and returns the value directly, because it only returns once there is one. Use it only when success is guaranteed eventually, for example polling an in-memory state that another thread will set.

The overload with a policy gives up after the policy's MaxRetries, sleeping between attempts for the delay the policy computes, and returns None if all attempts failed. The producer is called once immediately and then once per retry, so at most MaxRetries + 1 times.

Option<Response> response = Retry(
    () => TrySendRequest(),                              // Func<Option<Response>>
    new ExponentialBackOffRetryPolicy(maxRetries: 5, firstDelay: TimeSpan.FromMilliseconds(200)));

The exception-based form

Most APIs signal transient failure by throwing. This form wraps such a call: if the producer throws and shouldRetry returns true for the exception, it sleeps for the policy's delay and tries again, up to MaxRetries times. An exception that shouldRetry rejects, or the last retry's exception, propagates to the caller unchanged.

var content = Retry(
    () => httpClient.GetStringAsync(url).Result,
    shouldRetry: e => e is HttpRequestException,
    retryPolicy: new LinearBackOffRetryPolicy(maxRetries: 3, firstDelay: TimeSpan.FromSeconds(1)));

Both forms block the thread with Thread.Sleep during delays. In asynchronous code use RetryAsync and RetryAwaitAsync from AsyncFunctional, which await the delay, accept a CancellationToken, and have producers returning ValueTask. They live in Funcky.Async, and in Funcky itself on .NET 10.

The async forms check the token before every attempt and never retry once it is cancelled, even when shouldRetry would accept the exception. To abort an attempt that is already in progress, use the overloads whose producer receives the token:

var content = await RetryAwaitAsync(
    token => httpClient.GetStringAsync(url, token),
    shouldRetry: e => e is HttpRequestException,
    retryPolicy: new LinearBackOffRetryPolicy(maxRetries: 3, firstDelay: TimeSpan.FromSeconds(1)),
    cancellationToken);

Retry policies

A policy is an IRetryPolicy with two members: how many retries to make, and how long to wait before each one.

public interface IRetryPolicy
{
    int MaxRetries { get; }
    TimeSpan Delay(int retryCount);
}

Delay receives the number of the retry about to be made, starting at 1, and returns the time to wait before it. The built-in policies:

PolicyDelay before retry n
NoDelayRetryPolicy(maxRetries)none
ConstantDelayPolicy(maxRetries, delay)delay
LinearBackOffRetryPolicy(maxRetries, firstDelay)firstDelay × n
ExponentialBackOffRetryPolicy(maxRetries, firstDelay)firstDelay × 1.5ⁿ⁻¹, so the first retry waits firstDelay
DoNotRetryPolicy()no retries at all

DoNotRetryPolicy is useful as a default or in tests, where the retry wrapper should be transparent. Implement the interface yourself for jitter, a cap on the delay, or a delay that depends on external state.

Predicate Composition

Builds a new predicate from existing ones, so that Where, Any, All and friends can be given a composed condition without a lambda that restates it.

Func<T, bool> All<T>(params Func<T, bool>[] predicates)
Func<T, bool> Any<T>(params Func<T, bool>[] predicates)
Func<T, bool> Not<T>(Func<T, bool> predicate)

All is true when every predicate is true, Any when at least one is, Not inverts. They short-circuit like && and ||, and the predicates are evaluated left to right. All() with no predicates is always true and Any() with none always false, following the LINQ methods of the same names.

The point is reuse. Predicates that have names can be combined by name, where a lambda would have to spell out each one again:

Func<User, bool> isActive = u => u.IsActive;
Func<User, bool> isAdmin = u => u.Role == Role.Admin;
Func<User, bool> isVerified = u => u.EmailVerified;

var activeAdmins = users.Where(All(isActive, isAdmin));
var needsAttention = users.Where(Any(Not(isVerified), Not(isActive)));

// Compared to
var activeAdmins = users.Where(u => isActive(u) && isAdmin(u));

Because the result is an ordinary Func<T, bool>, it composes further: Not(All(a, b)) is "not both".

And, Or, Not as extension methods

The static forms cannot infer T from a method group, so Not(string.IsNullOrEmpty) does not compile without Not<string>(...). The same three combinators exist as extension methods on Func<T, bool> in Funcky.Extensions, where the receiver fixes the type:

Func<T, bool> And<T>(this Func<T, bool> predicate, params Func<T, bool>[] predicates)
Func<T, bool> Or<T>(this Func<T, bool> predicate, params Func<T, bool>[] predicates)
Func<T, bool> Not<T>(this Func<T, bool> predicate)

a.And(b, c) is All(a, b, c), a.Or(b, c) is Any(a, b, c) and a.Not() is Not(a), with the same short-circuiting. They read left to right, which suits longer chains:

var needsAttention = users.Where(isVerified.Not().Or(isActive.Not()));
var activeAdmins = users.Where(isActive.And(isAdmin));

And, Or, Not as extension methods

The static forms cannot infer from a method group, so does not compile without . The same three combinators exist as extension methods on in , where the receiver fixes the type:


is , is and is , with the same short-circuiting. They read left to right, which suits longer chains:

\

True and False

True and False are predicates that ignore their arguments and return a constant, with overloads for zero to four parameters:

bool True()
bool True<T1>(T1 ω1)
bool True<T1, T2>(T1 ω1, T2 ω2)
// … and the same for False

They are the predicate equivalents of Identity and NoOperation: the thing to pass when an API demands a predicate and you have no condition. SingleOrNone() is implemented as SingleOrNone(True).

// A feature flag that is always on in tests
Func<Request, bool> filter = isTestEnvironment ? True : IsAllowed;

// A default for an optional predicate parameter
IEnumerable<T> Filter<T>(IEnumerable<T> items, Func<T, bool>? predicate = null)
    => items.Where(predicate ?? True);

The unit type is an alternative to the native C# struct Void (with the global alias void).

The C# compiler handles Void/void very different to all other types in C#:

  • Void can never be used. You must always use void
  • void can not be instantiated
  • void can not be used as a generic argument to any function
  • A function (sometimes also called method) "returning" void does not return a value, and the result of the function (void) can not be assigned to any variable
  • When using reflection, void-methods will return null because the compiler can not know during compile-time what the dynamically dispatched method will return

These limitations on the void type introduce annoying behaviour, especially with expressions and generics.

Example 1 - the "void is not a real type" dilemma:

This examples uses a simple algebraic datatype with a generic match method:

public abstract class SimpleAlgebraicDatatype
{
	private SimpleAlgebraicDatatype()
	{
	}

	public abstract TResult Match<TResult>(
		Func<Variant1, TResult> variant1,
		Func<Variant2, TResult> variant2);

	public class Variant1 : SimpleAlgebraicDatatype
	{
		public Variant1(string someValue)
		{
			SomeValue = someValue;
		}

		public string SomeValue { get; }

		public override TResult Match<TResult>(
			Func<Variant1, TResult> variant1,
			Func<Variant2, TResult> variant2)
			=> variant1(this);
	}

	public class Variant2 : SimpleAlgebraicDatatype
	{
		public Variant2(int someValue)
		{
			SomeValue = someValue;
		}

		public int SomeValue { get; }

		public override TResult Match<TResult>(
			Func<Variant1, TResult> variant1,
			Func<Variant2, TResult> variant2)
			=> variant2(this);
	}
}

It then can be used like that:

SimpleAlgebraicDatatype variant = new SimpleAlgebraicDatatype.Variant1("Hey");
// get the variant as string
var value = variant.Match(
	variant1: variant1 => variant1.SomeValue,
	variant2: variant2 => Convert.ToString(variant2.SomeValue));
Console.WriteLine(value);

But if you don't want to use the return value, you're stuck with returning a value you don't want. This does not compile:

// Error [CS0411] The type arguments for method 'method' cannot be inferred from the usage. Try specifying the type arguments explicitly.
variant.Match(
	variant1: variant1 => Console.Write(variant1.SomeValue),
	variant2: variant2 => Console.Write(Convert.ToString(variant2.SomeValue)));

Now you have to decide what it returns. One option is to return null - the best fitting return type is probably object? in that case:

variant.Match<object?>(
	variant1: variant1 =>
	{
		Console.Write(variant1.SomeValue);
		return null;
	},
	variant2: variant2 =>
	{
		Console.Write(Convert.ToString(variant2.SomeValue));
		return null;
	});

This is very unstatisfying however. We have to trick the type-system. There should be a more expressive way.

Funcky.Unit to the rescue:

variant.Match(
	variant1: variant1 =>
	{
		Console.Write(variant1.SomeValue);
		return Unit.Value;
	},
	variant2: variant2 =>
	{
		Console.Write(Convert.ToString(variant2.SomeValue));
		return Unit.Value;
	});

Now this isn't really less noise. This is why we created ActionToUnit.

This clears up the code to:

variant.Match(
	variant1: variant1 => ActionToUnit(() => Console.Write(variant1.SomeValue)),
	variant2: variant2 => ActionToUnit(() => Console.Write(Convert.ToString(variant2.SomeValue))));

See ActionToUnit for an explanation.

Example 2 - the "switch expression must return something" dilemma:

The following two code snippets do not compile:

// Error [CS0201]: Only assignment, call, increment, decrement, and new object expressions can be used as a statement.
variant switch
{
	SimpleAlgebraicDatatype.Variant1 variant1 => Console.Write(variant1.SomeValue),
	SimpleAlgebraicDatatype.Variant2 variant2 => Console.Write(Convert.ToString(variant2.SomeValue)),
	_ => throw new Exception("Unreachable"),
};

// Error [CS0029]: Cannot implicitly convert type 'throw-expression' to 'void'
// Error [CS9209]: A value of type 'void' may not be assigned.
_ = variant switch
{
	SimpleAlgebraicDatatype.Variant1 variant1 => Console.Write(variant1.SomeValue),
	SimpleAlgebraicDatatype.Variant2 variant2 => Console.Write(Convert.ToString(variant2.SomeValue)),
	_ => throw new Exception("Unreachable"),
};

One way to resolve this dilemma is to return a method that returns null from every arm, and execute it after:

// very verbose and not very readable
Func<SimpleAlgebraicDatatype, object> action = variant switch
{
	SimpleAlgebraicDatatype.Variant1 variant1 => _ =>
	{
		Console.Write(Convert.ToString(variant1.SomeValue));
		return null;
	},
	SimpleAlgebraicDatatype.Variant2 variant2 => _ =>
	{
		Console.Write(Convert.ToString(variant2.SomeValue));
		return null;
	},
	_ => _ => throw new Exception("Unreachable"),
};
action(variant);

If we use ActionToUnit once again, we can simplify this code, by a lot:

_ = variant switch
{
	SimpleAlgebraicDatatype.Variant1 variant1 => ActionToUnit(() => Console.Write(variant1.SomeValue)),
	SimpleAlgebraicDatatype.Variant2 variant2 => ActionToUnit(() => Console.Write(Convert.ToString(variant2.SomeValue))),
};

If you cannot use the discard syntax (_ =), simply use var _ or similar, and ignore the variable after.

The ActionToUnit function wraps a action into a function that returns a Funcky.Unit instance.

You could write this method for yourself like this:

public static Func<Unit> ActionToUnit(Action action) => (Func<Unit>) (() =>
{
  action();
  return new Unit(); // or default, or Funcky.Unit.Value
});

However, if you now wanted a wrapper for a method with a parameter going into the action, you would need to write this:

public static Func<T, Unit> ActionToUnit<T>(Action<T> action) => (Func<T, Unit>) (parameter =>
{
  action(parameter);
  return new Unit();
});

Now with 2 parameters, you would need 2 generic parameters, and so on. We already support everything from 0 up to 8 in the static class Functional, so you can just write using static Funcky.Functional in your using section, and start using ActionToUnit.

For some use cases, see the Unit Type documentation.

Here one example with a switch expression:

var value = GetValue();
_ = value switch
{
	"Known" => ActionToUnit(() => Console.Write("Known")),
	_ => ActionToUnit(() => Console.Write("Unknown")),
};

IEnumerable Extensions

LINQ gives you the essential higher-order functions over IEnumerable<T>: Select, Where, SelectMany, Aggregate, and a few dozen more. Funcky adds the ones that are missing, and replaces the ones that return null, default or throw with variants that return an Option<T>.

All of them live in Funcky.Extensions.EnumerableExtensions, which is imported automatically when FunckyImplicitUsings is on. Most of them are lazy, in the same way LINQ's own operators are: nothing happens until the result is enumerated, and the input is enumerated at most once. The exceptions are noted on each operation's page.

Operations marked with ⁂ have an asynchronous counterpart on IAsyncEnumerable<T>, in Funcky itself on .NET 10 and in the Funcky.Async package on older targets. See Funcky.Async and IAsyncEnumerable.

Finding one element

The …OrNone family: like …OrDefault, but with an unambiguous answer for "nothing found". See Finding one element.

OperationDescription
FirstOrNone ⁂The first element, or the first matching a predicate
LastOrNone ⁂The last element, or the last matching a predicate
SingleOrNone ⁂The only element; throws if there is more than one
ElementAtOrNone ⁂The element at an index, also from the end
MinOrNone ⁂The smallest element or key
MaxOrNone ⁂The largest element or key
MinByOrNoneThe element with the smallest key
MaxByOrNoneThe element with the largest key
AverageOrNone ⁂The arithmetic mean
AggregateOrNone ⁂The seedless fold
GetNonEnumeratedCountOrNoneThe count, if it is known without enumerating

Grouping and windowing

Operations that cut one sequence into several. See Grouping and windowing.

OperationDescription
Chunk ⁂Consecutive pieces of a fixed size
SlidingWindow ⁂Overlapping windows of a fixed width
Pairwise ⁂Each element together with its successor
AdjacentGroupBy ⁂Group runs of consecutive elements with the same key
Split ⁂Cut at every occurrence of a separator
TakeEvery ⁂Every n-th element
Transpose ⁂Swap rows and columns of a sequence of sequences
PowerSet ⁂All subsets

Combining sequences

Operations that take several sequences and produce one. See Combining sequences.

OperationDescription
Merge ⁂Merge sorted sequences, keeping them sorted
Interleave ⁂Alternate elements from several sequences
Intersperse ⁂Put a separator element between every two elements
ZipLongest ⁂Zip two sequences without truncating the longer one
FlattenConcatenate a sequence of sequences
CartesianProductAll pairs, as a recipe with SelectMany

Context for each element

Operations that pair every element with information about its position. See Context for each element.

OperationDescription
WithIndex ⁂Each element with its zero-based index
WithFirst ⁂Each element with a flag telling whether it is the first
WithLast ⁂Each element with a flag telling whether it is the last
WithPrevious ⁂Each element with its predecessor as an Option
InclusiveScan, ExclusiveScan ⁂Running aggregate, like Aggregate yielding every intermediate

Filtering and partitioning

See Filtering and partitioning.

OperationDescription
WhereSelect ⁂Select with an Option-returning selector, dropping the Nones
WhereNotNull ⁂Drop the nulls and narrow the element type
Partition ⁂Split into two lists by a predicate, or by Either/Result case
None ⁂!Any, as a readable name
AnyOrElse ⁂The sequence itself, or a fallback if it is empty

Side effects and evaluation

Operations that run actions, or control when and how often the input is enumerated. See Side effects and evaluation.

OperationDescription
ForEachRun an action for each element, eagerly
Inspect ⁂Run an action for each element as it passes through, lazily
InspectEmpty ⁂Run an action if the sequence turns out to be empty
Materialize ⁂Evaluate once into a collection, unless it already is one
Memoize ⁂Evaluate lazily, but at most once, even for several consumers
Shuffle ⁂A random permutation

Strings and monads

See Strings and monads.

OperationDescription
JoinToString ⁂string.Join as an extension
ConcatToString ⁂string.Concat as an extension
Sequence ⁂Turn a sequence of Option/Result/Either inside out
Traverse ⁂Select with a monadic selector, then Sequence

Finding one element

LINQ's First, Last, Single, ElementAt, Min, Max, Average and the seedless Aggregate all share one problem: there may be nothing to return. The BCL answers this in two ways. The plain methods throw an InvalidOperationException, and the …OrDefault variants return default(T), which for a List<int> is 0, indistinguishable from a real element with the value 0, and for a reference type is a null you have to remember to check.

Funcky adds an …OrNone variant for each of them that returns an Option<T>. Empty input is None, and the value is only accessible once you have said what to do when there is none. All of them exist on IEnumerable<T>; FirstOrNone, LastOrNone, SingleOrNone and ElementAtOrNone also exist on IQueryable<T>, where the predicate is an expression tree and is translated by the query provider.

// Before
var first = numbers.FirstOrDefault();
if (first != 0) // wrong: 0 might be a legitimate first element
{
    ...
}

// After
numbers.FirstOrNone().Switch(
    none: () => ...,
    some: first => ...);

The element type is constrained to notnull. If your sequence contains nulls, filter them out first with WhereNotNull.

See the Option Monad chapter for what to do with the result, and the TryVerb-pattern for the same idea applied to the rest of the BCL.

FirstOrNone

Returns the first element of a sequence, or None if the sequence is empty. With a predicate, returns the first element that satisfies it, or None if no element does.

Option<TSource> FirstOrNone<TSource>(this IEnumerable<TSource> source)
Option<TSource> FirstOrNone<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
A marble diagram showing the FirstOrNone operation

FirstOrNone stops enumerating as soon as it has found an element, so it is safe to call on an infinite sequence as long as a matching element exists.

Example

var numbers = Sequence.Return(3, 1, 4, 1, 5);

numbers.FirstOrNone();              // Some(3)
numbers.FirstOrNone(n => n > 3);    // Some(4)
numbers.FirstOrNone(n => n > 10);   // None
Enumerable.Empty<int>().FirstOrNone(); // None

A typical use is to replace a FirstOrDefault followed by a null check with a chain that keeps the context:

Option<string> ownerName = accounts
    .FirstOrNone(account => account.Id == id)
    .Select(account => account.Owner.Name);

LastOrNone

Returns the last element of a sequence, or None if the sequence is empty. With a predicate, returns the last element that satisfies it, or None if no element does.

Option<TSource> LastOrNone<TSource>(this IEnumerable<TSource> source)
Option<TSource> LastOrNone<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
A marble diagram showing the LastOrNone operation

Unlike FirstOrNone, LastOrNone has to enumerate the whole sequence before it can answer, so it never returns on an infinite sequence.

Example

var numbers = Sequence.Return(3, 1, 4, 1, 5);

numbers.LastOrNone();               // Some(5)
numbers.LastOrNone(n => n < 3);     // Some(1)
numbers.LastOrNone(n => n > 10);    // None

SingleOrNone

Returns the only element of a sequence, or None if the sequence is empty. With a predicate, returns the only element that satisfies it.

Option<TSource> SingleOrNone<TSource>(this IEnumerable<TSource> source)
Option<TSource> SingleOrNone<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
A marble diagram showing the SingleOrNone operation

SingleOrNone keeps the contract of SingleOrDefault: an empty sequence is a legitimate answer and yields None, but more than one matching element is a programming error and throws an InvalidOperationException. If "zero or more than one" should both be None, use Where and a count, or FirstOrNone if you only care about the first match.

To find out whether there is exactly one element, SingleOrNone has to look at the second one, so it enumerates at most two elements.

Example

Sequence.Return(4).SingleOrNone();             // Some(4)
Enumerable.Empty<int>().SingleOrNone();        // None
Sequence.Return(4, 4).SingleOrNone();          // throws InvalidOperationException

var users = Sequence.Return("alice", "bob");
users.SingleOrNone(u => u.StartsWith('a'));    // Some("alice")
users.SingleOrNone(u => u.StartsWith('z'));    // None

ElementAtOrNone

Returns the element at the given position, or None if the position is outside the sequence.

Option<TSource> ElementAtOrNone<TSource>(this IEnumerable<TSource> source, int index)
Option<TSource> ElementAtOrNone<TSource>(this IEnumerable<TSource> source, Index index)   // .NET 6+
A marble diagram showing the ElementAtOrNone operation

A negative index is not an error, it is simply not in the sequence and yields None. The Index overload accepts positions from the end (^1 is the last element); counting from the end may require enumerating the whole sequence.

Example

var numbers = Sequence.Return(3, 1, 4, 1, 5);

numbers.ElementAtOrNone(2);     // Some(4)
numbers.ElementAtOrNone(5);     // None
numbers.ElementAtOrNone(-1);    // None
numbers.ElementAtOrNone(^1);    // Some(5)

MinOrNone

Returns the smallest element of a sequence, or None if the sequence is empty.

Option<TSource> MinOrNone<TSource>(this IEnumerable<TSource> source)
Option<TSource> MinOrNone<TSource>(this IEnumerable<Option<TSource>> source)
Option<TResult> MinOrNone<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
Option<TResult> MinOrNone<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, Option<TResult>> selector)
A marble diagram showing the MinOrNone operation

The BCL's Min returns null for an empty sequence of nullable values but throws for an empty sequence of int. MinOrNone treats both the same: empty is None. Elements are compared with Comparer<T>.Default, and when several elements are equal the first one wins.

The overloads taking Option<T> elements, or a selector returning Option<T>, skip the None elements, in the same way the BCL's Min skips null. A sequence consisting only of None is therefore empty and yields None.

Example

var numbers = Sequence.Return(3, 1, 4, 1, 5);

numbers.MinOrNone();                       // Some(1)
Enumerable.Empty<int>().MinOrNone();       // None
numbers.MinOrNone(n => n * -1);            // Some(-5)

var readings = Sequence.Return(Option.Some(3), Option<int>.None, Option.Some(2));
readings.MinOrNone();                      // Some(2)

MaxOrNone

Returns the largest element of a sequence, or None if the sequence is empty.

Option<TSource> MaxOrNone<TSource>(this IEnumerable<TSource> source)
Option<TSource> MaxOrNone<TSource>(this IEnumerable<Option<TSource>> source)
Option<TResult> MaxOrNone<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
Option<TResult> MaxOrNone<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, Option<TResult>> selector)
A marble diagram showing the MaxOrNone operation

MaxOrNone mirrors MinOrNone in every respect: empty is None, comparison uses Comparer<T>.Default, the first of several equal elements wins, and None elements are skipped.

Example

var numbers = Sequence.Return(3, 1, 4, 1, 5);

numbers.MaxOrNone();                       // Some(5)
Enumerable.Empty<int>().MaxOrNone();       // None
numbers.MaxOrNone(n => n % 3);             // Some(2)

MinByOrNone

Returns the element with the smallest key, or None if the sequence is empty.

Option<TSource> MinByOrNone<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
Option<TSource> MinByOrNone<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IComparer<TKey> comparer)
A marble diagram showing the MinByOrNone operation

Where MinOrNone with a selector returns the smallest key, MinByOrNone returns the element that has it. This is the same distinction as between Min(selector) and MinBy in the BCL, which gained MinBy in .NET 6 and throws on an empty sequence of value types. MinByOrNone is available on every target framework and returns None instead.

The first element with the smallest key wins when there are ties. The key selector is called once per comparison, so keep it cheap or project the key first.

Example

var animals = Sequence.Return(
    new Animal("Elephant", Weight: 6000),
    new Animal("Mouse", Weight: 0.02),
    new Animal("Cat", Weight: 4),
    new Animal("Dog", Weight: 30));

animals.MinByOrNone(animal => animal.Weight);     // Some(Mouse)
animals.MinOrNone(animal => animal.Weight);       // Some(0.02)
Enumerable.Empty<Animal>().MinByOrNone(a => a.Weight); // None

MaxByOrNone

Returns the element with the largest key, or None if the sequence is empty.

Option<TSource> MaxByOrNone<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
Option<TSource> MaxByOrNone<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IComparer<TKey> comparer)
A marble diagram showing the MaxByOrNone operation

MaxByOrNone mirrors MinByOrNone: it returns the element rather than the key, the first element wins on ties, and an empty sequence yields None instead of throwing like the BCL's MaxBy.

Example

animals.MaxByOrNone(animal => animal.Weight);     // Some(Elephant)
animals.MaxByOrNone(animal => animal.Name.Length, StringComparer.Ordinal); // Some(Elephant)

AverageOrNone

Returns the arithmetic mean of a sequence of numbers, or None if the sequence is empty.

Option<double> AverageOrNone(this IEnumerable<int> source)
Option<double> AverageOrNone(this IEnumerable<long> source)
Option<float> AverageOrNone(this IEnumerable<float> source)
Option<double> AverageOrNone(this IEnumerable<double> source)
Option<decimal> AverageOrNone(this IEnumerable<decimal> source)

Each of these also exists for a sequence of Option<T> of the same numeric type, and with a selector (Func<TSource, int>, Func<TSource, Option<int>>, and so on), matching the overload set of the BCL's Average.

A marble diagram showing the AverageOrNone operation

The BCL's Average throws an InvalidOperationException on an empty sequence of non-nullable numbers, and returns null on an empty sequence of nullable numbers. AverageOrNone returns None in both cases. None elements are skipped and do not count towards the divisor, exactly like null elements in the BCL.

The result type follows the BCL: int and long average to double, the floating point types to themselves.

Example

Sequence.Return(3, 1, 4, 1, 6).AverageOrNone();    // Some(3.0)
Enumerable.Empty<int>().AverageOrNone();           // None

var temperatures = Sequence.Return(Option.Some(20.0), Option<double>.None, Option.Some(22.0));
temperatures.AverageOrNone();                      // Some(21.0)

orders.AverageOrNone(order => order.Total);        // Option<decimal>

AggregateOrNone

Folds a sequence with an accumulator function, using the first element as the seed, or returns None if the sequence is empty.

Option<TSource> AggregateOrNone<TSource>(this IEnumerable<TSource> source, Func<TSource, TSource, TSource> func)

This is the …OrNone variant of the seedless Aggregate, which throws an InvalidOperationException for an empty sequence. The seeded overloads of Aggregate have no such problem, because the seed is the answer for an empty sequence, so they have no …OrNone variant. For the same reason there is no SumOrNone: the sum of nothing is zero.

The accumulator is applied from the left and is never invoked for a sequence with a single element; that element is the result.

Example

var words = Sequence.Return("functional", "programming", "in", "C#");

words.AggregateOrNone((longest, word) => word.Length > longest.Length ? word : longest);   // Some("programming")
Enumerable.Empty<string>().AggregateOrNone((a, b) => a + b);                             // None

GetNonEnumeratedCountOrNone

Returns the number of elements if it can be determined without enumerating the sequence, otherwise None.

Option<int> GetNonEnumeratedCountOrNone<TSource>(this IEnumerable<TSource> source)

This wraps the BCL's TryGetNonEnumeratedCount, which succeeds for collections that know their size, such as arrays, List<T> and ICollection<T>, and for some LINQ operators over them. It fails for anything that would need to be iterated, for example the result of a Where. On frameworks older than .NET 6 Funcky ships its own check, which recognizes collections with a Count but none of the LINQ operators.

Use it when you want to size a buffer or show a count up front but must not pay for a full enumeration, and have a fallback for when the count is unknown:

string Describe<T>(IEnumerable<T> items)
    => items.GetNonEnumeratedCountOrNone()
        .Select(count => $"{count} items")
        .GetOrElse("some items");

Grouping and windowing

These operations cut one sequence into several smaller ones. They differ in whether the pieces overlap (SlidingWindow, Pairwise), have a fixed size (Chunk) or are delimited by the data itself (AdjacentGroupBy, Split).

Chunk

Cuts a sequence into consecutive pieces of a fixed size. The last piece is smaller if the length is not a multiple of the size.

IEnumerable<IReadOnlyList<TSource>> Chunk<TSource>(this IEnumerable<TSource> source, int size)
IEnumerable<TResult> Chunk<TSource, TResult>(this IEnumerable<TSource> source, int size, Func<IReadOnlyList<TSource>, TResult> resultSelector)
A marble diagram showing the Chunk operation

A size of zero or less throws an ArgumentOutOfRangeException immediately, not on enumeration. The chunks are produced lazily, but each chunk is materialized when it is yielded, so you can hold on to a chunk after moving to the next one.

.NET 6 added Enumerable.Chunk to the BCL, returning arrays. On those targets Funcky's own Chunk(size) steps aside and the BCL method is picked; the overload with a result selector remains Funcky's, and is the shorter way to write Chunk(size).Select(selector).

Example

var numbers = Sequence.Return(1, 2, 3, 4, 5, 6, 7);

numbers.Chunk(3);
// [[1, 2, 3], [4, 5, 6], [7]]

var magicSquare = Sequence.Return(4, 9, 2, 3, 5, 7, 8, 1, 6);
magicSquare.Chunk(3, row => row.Sum());
// [15, 15, 15]

SlidingWindow

Produces every window of a fixed width, moving one element at a time. Consecutive windows overlap in all but one element.

IEnumerable<IReadOnlyList<TSource>> SlidingWindow<TSource>(this IEnumerable<TSource> source, int width)
A marble diagram showing the SlidingWindow operation

Only full windows are produced. A sequence shorter than the width yields nothing, and a sequence of exactly the width yields one window. A width of zero or less throws an ArgumentOutOfRangeException. The windows are produced lazily and each one is an immutable snapshot.

SlidingWindow(2) is Pairwise with lists instead of tuples; use Pairwise when the width is two.

Example

A moving average over the last three readings:

var readings = Sequence.Return(2, 4, 6, 8, 10);

readings.SlidingWindow(3);
// [[2, 4, 6], [4, 6, 8], [6, 8, 10]]

readings.SlidingWindow(3).Select(window => window.Average());
// [4, 6, 8]

Pairwise

Pairs each element with the one that follows it.

IEnumerable<(TSource Front, TSource Back)> Pairwise<TSource>(this IEnumerable<TSource> source)
IEnumerable<TResult> Pairwise<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TSource, TResult> resultSelector)
A marble diagram showing the Pairwise operation

A sequence of n elements produces n - 1 pairs; an empty sequence or a single element produces nothing. Every element except the first and the last appears twice, once as Back and once as Front. The operation is lazy and holds on to only one element at a time.

Compare WithPrevious, which keeps every element and makes the predecessor an Option, so the first element is not dropped.

Example

Differences between consecutive measurements:

var temperatures = Sequence.Return(20, 22, 21, 25);

temperatures.Pairwise();
// [(20, 22), (22, 21), (21, 25)]

temperatures.Pairwise((before, after) => after - before);
// [2, -1, 4]

AdjacentGroupBy

Groups runs of consecutive elements that share a key. Where GroupBy collects all elements with the same key from the whole sequence, AdjacentGroupBy starts a new group every time the key changes.

IEnumerable<IGrouping<TKey, TSource>> AdjacentGroupBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
IEnumerable<IGrouping<TKey, TSource>> AdjacentGroupBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IEqualityComparer<TKey> comparer)

Like GroupBy, there are further overloads with an element selector, a result selector taking the key and the elements, or both, each with and without a comparer.

A marble diagram showing the AdjacentGroupBy operation

The same key can therefore appear in several groups, as a does in the diagram. On a sequence that is sorted by the key, the result is identical to GroupBy, and that is the typical use: the data is already ordered, and AdjacentGroupBy can stream it, yielding each group as soon as the next key is seen, instead of reading everything into a lookup first. Each group is materialized when it is yielded.

This is the same operation as Haskell's groupBy and Python's itertools.groupby.

Example

Collapsing a log into runs of the same severity:

var entries = Sequence.Return(
    new LogEntry(Level.Info, "started"),
    new LogEntry(Level.Info, "listening"),
    new LogEntry(Level.Error, "timeout"),
    new LogEntry(Level.Info, "recovered"));

entries.AdjacentGroupBy(entry => entry.Level, (level, run) => $"{run.Count()} × {level}");
// ["2 × Info", "1 × Error", "1 × Info"]

Splitting a date-ordered sequence into days:

var byDay = events.AdjacentGroupBy(e => DateOnly.FromDateTime(e.Timestamp));

Split

Cuts a sequence at every occurrence of a separator element, like string.Split on a string.

IEnumerable<IReadOnlyList<TSource>> Split<TSource>(this IEnumerable<TSource> source, TSource separator)
IEnumerable<IReadOnlyList<TSource>> Split<TSource>(this IEnumerable<TSource> source, TSource separator, IEqualityComparer<TSource> comparer)
IEnumerable<TResult> Split<TSource, TResult>(this IEnumerable<TSource> source, TSource separator, IEqualityComparer<TSource> comparer, Func<IReadOnlyList<TSource>, TResult> resultSelector)
A marble diagram showing the Split operation

The separators themselves are not part of the result. The result has exactly one part more than there are separators, exactly like string.Split: two adjacent separators produce an empty part between them, a separator at the very start produces an empty first part, a separator at the very end produces an empty last part, and an empty source produces a single empty part. Joining the parts with the separator therefore always restores the source. The parts are produced lazily and each part is materialized when it is yielded.

Example

Splitting a token stream into statements:

var tokens = Sequence.Return("let", "x", ";", "print", "x", ";", ";", "exit");

tokens.Split(";");
// [["let", "x"], ["print", "x"], [], ["exit"]]

tokens.Split(";", StringComparer.Ordinal, statement => string.Join(' ', statement));
// ["let x", "print x", "", "exit"]

TakeEvery

Keeps the first element and then every n-th element after it.

IEnumerable<TSource> TakeEvery<TSource>(this IEnumerable<TSource> source, int interval)
A marble diagram showing the TakeEvery operation

The elements at indices 0, n, 2n, … are kept. To start at a different offset, Skip first. An interval of zero or less throws an ArgumentOutOfRangeException immediately; TakeEvery(1) is the identity.

Example

Enumerable.Range(1, 10).TakeEvery(3);
// [1, 4, 7, 10]

Enumerable.Range(1, 10).Skip(1).TakeEvery(3);
// [2, 5, 8]

Transpose

Swaps rows and columns of a sequence of sequences, so that the first result row holds the first element of every input row, and so on.

IEnumerable<IReadOnlyList<TSource>> Transpose<TSource>(this IEnumerable<IEnumerable<TSource>> source)
A marble diagram showing the Transpose operation

All input rows must have the same length; the behaviour for jagged input is unspecified. The outer sequence is enumerated more than once, so pass a materialized collection rather than a one-shot iterator. An empty outer sequence transposes to an empty sequence.

Example

var matrix = Sequence.Return(
    Sequence.Return(1, 2, 3),
    Sequence.Return(4, 5, 6));

matrix.Transpose();
// [[1, 4], [2, 5], [3, 6]]

Turning a list of records into columns, for example to compute a per-column statistic:

var columns = rows.Select(row => row.Values).Transpose();
var columnMaxima = columns.Select(column => column.MaxOrNone());

PowerSet

Produces every subset of the input, including the empty set and the input itself.

IEnumerable<IEnumerable<TSource>> PowerSet<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the PowerSet operation

A sequence of n elements has 2ⁿ subsets, so this is only practical for small inputs. The subsets are produced in a fixed order: first all subsets without the last input element, then the same subsets with it added. The whole input is read before the first subset is yielded. Elements are treated by position, not by value, so duplicates in the input produce duplicate subsets.

Example

Sequence.Return("a", "b", "c").PowerSet();
// [[], [a], [b], [a, b], [c], [a, c], [b, c], [a, b, c]]

All ways to choose toppings whose total price fits a budget:

var affordable = toppings
    .PowerSet()
    .Where(choice => choice.Sum(t => t.Price) <= budget);

Combining sequences

These operations take two or more sequences and produce one. They differ in how they pick the next element: Merge by comparing, Interleave by taking turns, ZipLongest by position, Flatten by exhausting one sequence before starting the next. Intersperse is the odd one out: it combines a sequence with a single element.

All of them are lazy and enumerate each input at most once.

Merge

Merges two or more sorted sequences into one sorted sequence, like the merge step of a merge sort.

IEnumerable<TSource> Merge<TSource>(this IEnumerable<TSource> source1, IEnumerable<TSource> source2, Option<IComparer<TSource>> comparer = default)
IEnumerable<TSource> Merge<TSource>(this IEnumerable<TSource> source1, IEnumerable<TSource> source2, IEnumerable<TSource> source3, Option<IComparer<TSource>> comparer = default)
IEnumerable<TSource> Merge<TSource>(this IEnumerable<TSource> source1, IEnumerable<TSource> source2, IEnumerable<TSource> source3, IEnumerable<TSource> source4, Option<IComparer<TSource>> comparer = default)
IEnumerable<TSource> Merge<TSource>(this IEnumerable<IEnumerable<TSource>> sources, Option<IComparer<TSource>> comparer = default)
A marble diagram showing the Merge operation

The inputs must already be sorted by the same comparer, which defaults to Comparer<T>.Default. Merge does not check this; on unsorted input it still produces all elements, but not in order. At each step it looks at the current head of every input and yields the smallest. When two heads compare equal, the one from the earlier input wins, so the merge is stable.

Merge is lazy and streams: it holds one element per input, never materializes anything, and works on infinite inputs. This is what distinguishes it from Concat(...).OrderBy(...), which has to read everything first.

Example

var odds = Sequence.Return(1, 3, 5, 9);
var evens = Sequence.Return(2, 4, 6);

odds.Merge(evens);
// [1, 2, 3, 4, 5, 6, 9]

var byNewest = Comparer<LogEntry>.Create((a, b) => b.Timestamp.CompareTo(a.Timestamp));
var combinedLog = Sequence.Return(webLog, dbLog, workerLog).Merge(byNewest);

Interleave

Takes one element from each sequence in turn, round-robin, until all sequences are exhausted.

IEnumerable<TSource> Interleave<TSource>(this IEnumerable<TSource> source, params IEnumerable<TSource>[] otherSources)
IEnumerable<TSource> Interleave<TSource>(this IEnumerable<IEnumerable<TSource>> source)
A marble diagram showing the Interleave operation

Sequences of different lengths are fine: a sequence that runs out simply drops out of the rotation and the remaining ones continue. The result therefore contains every element of every input. Interleave is lazy and advances each input only when it is that input's turn.

For two sequences of the same length, Interleave is Zip followed by flattening the pairs.

Example

var numbers = Sequence.Return(1, 2, 3, 4);
var letters = Sequence.Return("a", "b");

numbers.Select(n => n.ToString()).Interleave(letters);
// ["1", "a", "2", "b", "3", "4"]

Alternating rows from several sources in a fair way, for example to mix results of different providers:

var mixed = Sequence.Return(newsFeed, blogFeed, podcastFeed).Interleave().Take(30);

Intersperse

Inserts a separator element between every two elements of a sequence.

IEnumerable<TSource> Intersperse<TSource>(this IEnumerable<TSource> source, TSource element)
A marble diagram showing the Intersperse operation

The separator goes only between elements: an empty sequence stays empty, and a single element is returned without a separator. A sequence of n elements becomes one of 2n - 1. The operation is lazy.

This is the sequence counterpart of string.Join: JoinToString(", ") is Intersperse(", ") followed by concatenation. Use Intersperse when the result should stay a sequence, for example to put spacer items into a list of UI elements.

Example

Sequence.Return(9, 7, 3).Intersperse(0);
// [9, 0, 7, 0, 3]

Sequence.Return("a").Intersperse("-");
// ["a"]

var layout = widgets.Intersperse(new Spacer());

ZipLongest

Pairs up two sequences element by element, like Zip, but keeps going until the longer one is exhausted.

IEnumerable<EitherOrBoth<TLeft, TRight>> ZipLongest<TLeft, TRight>(this IEnumerable<TLeft> left, IEnumerable<TRight> right)
IEnumerable<TResult> ZipLongest<TLeft, TRight, TResult>(this IEnumerable<TLeft> left, IEnumerable<TRight> right, Func<EitherOrBoth<TLeft, TRight>, TResult> resultSelector)
A marble diagram showing the ZipLongest operation

Zip stops at the end of the shorter sequence and silently drops the rest of the longer one. ZipLongest instead produces an EitherOrBoth<L, R> for every position: Both(left, right) while both sequences have elements, then Left(left) or Right(right) for the remaining elements of whichever is longer. There is no default value to invent, so nothing is invented.

EitherOrBoth is handled with Match or Switch, which take three functions. The result selector overload is the shorter way to write ZipLongest(right).Select(selector).

Example

var expected = Sequence.Return("a", "b", "c");
var actual = Sequence.Return("a", "x");

expected.ZipLongest(actual, pair => pair.Match(
    both: (e, a) => e == a ? $"{e} ok" : $"{e} != {a}",
    left: e => $"{e} missing",
    right: a => $"{a} unexpected"));
// ["a ok", "b != x", "c missing"]

Flatten

Concatenates a sequence of sequences into one sequence.

IEnumerable<T> Flatten<T>(this IEnumerable<IEnumerable<T>> enumerable)
A marble diagram showing the Flatten operation

Flatten is SelectMany(Identity): the inner sequences are enumerated one after the other, in order, lazily. It is the inverse of Chunk, and exists because SelectMany(x => x) says much less than its name.

The same operation on a known number of sequences is Sequence.Concat(first, second, third), which is the variadic form of LINQ's Concat.

Example

var pages = Sequence.Return(
    Sequence.Return(1, 2, 3),
    Sequence.Return(4, 5),
    Enumerable.Empty<int>(),
    Sequence.Return(6));

pages.Flatten();
// [1, 2, 3, 4, 5, 6]

numbers.Chunk(3).Flatten().SequenceEqual(numbers);
// true

CartesianProduct

The Cartesian product of two sequences is the sequence of all pairs with the first element from the first sequence and the second from the second. Funcky does not ship a method for it, because LINQ already has one under a different name, and this section exists so that you find it.

A marble diagram showing the CartesianProduct operation

Recipe

SelectMany with a selector that ignores its argument is the Cartesian product:

// As tuples
var pairs = first.SelectMany(_ => second, ValueTuple.Create);

// With a selector
var combined = first.SelectMany(_ => second, (a, b) => ...);

// In query syntax, which reads most naturally
var combined =
    from a in first
    from b in second
    select ...;

The second sequence is enumerated once per element of the first, so if it is expensive to produce, materialize it before.

Example

Every playing card is a suit combined with a rank:

var suits = Sequence.Return("♠", "♣", "♥", "♦");
var ranks = Sequence.Return("2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A");

var deck =
    from suit in suits
    from rank in ranks
    select $"{rank}{suit}";
// ["2♠", "3♠", …, "A♦"], 52 cards

Context for each element

A foreach loop often needs to know more than the element itself: is this the first one, is it the last one, what came before it, what is its index. The usual answer is a counter or a flag declared outside the loop and updated inside it. These operations pair each element with that information instead, so it is available in a Select or a query, and the loop state disappears.

The four With… operations return small structs with a Value property and the extra information, each with a Deconstruct so they work in tuple patterns and foreach. All are lazy, and when the source is an IList<T> the result is a list too, so Count and indexing stay cheap.

InclusiveScan and ExclusiveScan generalise the idea: the context is an arbitrary state accumulated over all preceding elements.

WithIndex

Pairs each element with its zero-based position.

IEnumerable<ValueWithIndex<TSource>> WithIndex<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the WithIndex operation

ValueWithIndex<T> has a Value and an Index property and deconstructs in that order. Like all the With… operations it is lazy, and when the source is an IList<T> the result is a list as well, so it keeps Count and indexing without enumerating.

.NET 9 added Index() to LINQ, which returns (int Index, T Item) tuples. WithIndex is available on every target framework and puts the value first, matching the other With… types.

Example

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

names.WithIndex().Select(n => $"{n.Index + 1}. {n.Value}");
// ["1. Ada", "2. Grace", "3. Linus"]

foreach (var (name, index) in names.WithIndex())
{
    Console.WriteLine($"{index}: {name}");
}

WithFirst

Pairs each element with a flag telling whether it is the first one.

IEnumerable<ValueWithFirst<TSource>> WithFirst<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the WithFirst operation

ValueWithFirst<T> has Value and IsFirst and deconstructs in that order. Exactly one element has IsFirst == true, unless the sequence is empty. The operation is lazy and streams; it needs no lookahead.

Example

Rendering a list where the first entry gets special treatment:

var lines = steps.WithFirst().Select(step => step.IsFirst
    ? $"Start with {step.Value}"
    : $"then {step.Value}");

This is also how Intersperse is implemented: emit a separator before every element that is not the first.

WithLast

Pairs each element with a flag telling whether it is the last one.

IEnumerable<ValueWithLast<TSource>> WithLast<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the WithLast operation

ValueWithLast<T> has Value and IsLast and deconstructs in that order. To know that an element is the last one, WithLast has to try reading the next one first. On a plain IEnumerable<T> it therefore runs one element behind the source: each element is yielded only when its successor has been read, or the end has been reached. On an IList<T> the count is known and there is no delay.

The delay matters for sources that block, such as a stream of events: the last event is not visible until the source completes.

Example

Joining with a terminator, in the way string.Join cannot:

var sql = columns
    .WithLast()
    .Select(column => column.IsLast ? $"{column.Value};" : $"{column.Value},")
    .ConcatToString();

WithPrevious

Pairs each element with the element before it, as an Option.

IEnumerable<ValueWithPrevious<TSource>> WithPrevious<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the WithPrevious operation

ValueWithPrevious<T> has Value and Previous, where Previous is None for the first element and Some of the predecessor for every other one. The result has as many elements as the input. Compare Pairwise, which produces tuples of neighbours and therefore one element fewer, dropping the first.

The element type is constrained to notnull, because Previous is an Option<T>.

Example

Marking where a sorted sequence changes value:

var readings = Sequence.Return(1, 1, 2, 2, 2, 3);

readings
    .WithPrevious()
    .Where(r => r.Previous.Select(p => p != r.Value).GetOrElse(true))
    .Select(r => r.Value);
// [1, 2, 3]

InclusiveScan, ExclusiveScan

A scan is an Aggregate that yields every intermediate state instead of only the final one. The classic example is the running total, also called the prefix sum.

IEnumerable<TAccumulate> InclusiveScan<TSource, TAccumulate>(this IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate, TSource, TAccumulate> accumulator)
IEnumerable<TAccumulate> ExclusiveScan<TSource, TAccumulate>(this IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate, TSource, TAccumulate> accumulator)
A marble diagram showing the InclusiveScan and ExclusiveScan operations

The two variants differ in whether an element's own contribution is included in the state yielded for it:

  • InclusiveScan yields the state after folding in each element. The last value is what Aggregate would return.
  • ExclusiveScan yields the state before folding in each element. The first value is the seed, and the final accumulated state is never yielded.

Both produce exactly as many elements as the input and are lazy, which makes them usable on infinite sequences.

Example

var amounts = Sequence.Return(10, 20, 30);

amounts.InclusiveScan(0, (sum, amount) => sum + amount);
// [10, 30, 60]

amounts.ExclusiveScan(0, (sum, amount) => sum + amount);
// [0, 10, 30]

The exclusive scan answers "where does each element start", for example the offset of each block in a buffer:

var offsets = blocks.ExclusiveScan(0, (offset, block) => offset + block.Length);

The inclusive scan answers "what is the state so far", for example an account balance after every transaction:

var balances = transactions.InclusiveScan(openingBalance, (balance, t) => balance + t.Amount);

Filtering and partitioning

LINQ's Where keeps or drops elements by a predicate. These operations cover the cases around it: filtering and transforming in one step when the transformation can fail (WhereSelect), removing null while fixing the type (WhereNotNull), keeping both halves instead of only one (Partition), asking whether anything is left (None), and substituting a fallback when nothing is (AnyOrElse).

WhereSelect

Select with a selector that may decline: the selector returns an Option<T>, and the Nones are dropped.

IEnumerable<TResult> WhereSelect<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, Option<TResult>> selector)
IEnumerable<TResult> WhereSelect<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, int, Option<TResult>> selector)
IEnumerable<TSource> WhereSelect<TSource>(this IEnumerable<Option<TSource>> source)
A marble diagram showing the WhereSelect operation

This is the operation that makes the …OrNone methods compose with sequences. Where you would otherwise write a Where to filter out the inputs that will fail, followed by a Select that transforms the rest, often duplicating the check, WhereSelect does both in one step with one function. In the "option is a list with at most one element" picture from the Option chapter, it is simply SelectMany.

The overload without a selector flattens a sequence of options, keeping the Some values. The indexed overload hands the selector the element's position as well, like the indexed Select.

WhereSelect is lazy. The selector is called once per element.

Example

Parsing what can be parsed:

var input = Sequence.Return("12", "abc", "7", "", "40");

input.WhereSelect(ParseExtensions.ParseInt32OrNone);
// [12, 7, 40]

Looking up what exists:

IEnumerable<User> FindUsers(IEnumerable<int> ids)
    => ids.WhereSelect(usersById.GetValueOrNone);

Keeping only the present values of a sequence of options:

var found = Sequence.Return(Option.Some(1), Option<int>.None, Option.Some(3)).WhereSelect();
// [1, 3]

WhereNotNull

Drops the null elements of a sequence, and narrows the element type from T? to T.

IEnumerable<TSource> WhereNotNull<TSource>(this IEnumerable<TSource?> source) where TSource : class
IEnumerable<TSource> WhereNotNull<TSource>(this IEnumerable<TSource?> source) where TSource : struct
A marble diagram showing the WhereNotNull operation

Where(x => x != null) removes the nulls but leaves the static type as IEnumerable<T?>, so every later use still needs a null check or a !. WhereNotNull changes the type, which is the whole point: the compiler knows the nulls are gone. It works for nullable reference types and for Nullable<T>, unwrapping the latter.

It is the bridge from APIs that return null into Funcky's notnull-constrained world, and the first step before FirstOrNone and friends on a sequence with nullable elements.

Example

IEnumerable<string?> lines = Sequence.Return("a", null, "b");
IEnumerable<string> nonNull = lines.WhereNotNull();
// ["a", "b"]

IEnumerable<int?> readings = Sequence.Return<int?>(1, null, 3);
IEnumerable<int> values = readings.WhereNotNull();
// [1, 3]

var envValues = names
    .Select(Environment.GetEnvironmentVariable)
    .WhereNotNull();

Partition

Splits a sequence into two lists in a single pass: the elements for which a predicate holds and those for which it does not. Further overloads split a sequence of Either or Result values by their case.

Partitions<TSource> Partition<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
TResult Partition<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, bool> predicate, Func<IReadOnlyList<TSource>, IReadOnlyList<TSource>, TResult> resultSelector)
A marble diagram showing the Partition operation

Partition is eager: it enumerates the whole input once and returns two materialized lists, in the original order. The alternative, Where(p) and Where(x => !p(x)), enumerates the input twice and evaluates the predicate twice per element. The result struct has True and False properties and deconstructs in that order. The result selector overload receives the two lists directly, which saves the intermediate struct when you only want a projection of them.

Example

var (evens, odds) = Enumerable.Range(1, 8).Partition(n => n % 2 == 0);
// evens: [2, 4, 6, 8]
// odds:  [1, 3, 5, 7]

var summary = orders.Partition(
    order => order.IsPaid,
    (paid, unpaid) => $"{paid.Count} paid, {unpaid.Count} outstanding");

Partitioning by Either and Result

A sequence of Either<L, R> splits into the left and the right values, and a sequence of Result<T> into the exceptions and the ok values. For Either there are also overloads that take the selector producing the eithers, so the Select can be folded into the Partition.

EitherPartitions<TLeft, TRight> Partition<TLeft, TRight>(this IEnumerable<Either<TLeft, TRight>> source)
EitherPartitions<TLeft, TRight> Partition<TSource, TLeft, TRight>(this IEnumerable<TSource> source, Func<TSource, Either<TLeft, TRight>> selector)
ResultPartitions<TValidResult> Partition<TValidResult>(this IEnumerable<Result<TValidResult>> source)

Each of these has a counterpart with a result selector receiving the two lists.

A marble diagram showing the Partition operation on a sequence of Either values

EitherPartitions deconstructs into (left, right), ResultPartitions into (error, ok). This is the "process everything and report all failures" counterpart to Sequence and Traverse, which stop at the first failure; see the Result chapter.

var (errors, orders) = requests
    .Select(request => PlaceOrder(request))   // each returns Result<Order>
    .Partition();

var (invalid, valid) = lines.Partition(line => ValidateLine(line));   // returns Either<ValidationError, Record>

None

Returns true if the sequence is empty, or if no element satisfies the predicate. It is !Any(), with the negation where it is easy to see.

bool None<TSource>(this IEnumerable<TSource> source)
bool None<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)

Like Any, None stops at the first element that decides the answer, so it is safe on infinite sequences as long as a matching element exists.

!items.Any(x => x.IsValid) reads as "not any valid" and is easy to confuse with items.Any(x => !x.IsValid), which means something else. items.None(x => x.IsValid) says what it checks.

Example

if (errors.None())
{
    Commit();
}

bool allSettled = payments.None(p => p.IsPending);

AnyOrElse

Returns the sequence itself if it has at least one element, otherwise a fallback sequence.

IEnumerable<TSource> AnyOrElse<TSource>(this IEnumerable<TSource> source, IEnumerable<TSource> fallback)
IEnumerable<TSource> AnyOrElse<TSource>(this IEnumerable<TSource> source, Func<IEnumerable<TSource>> fallback)
A marble diagram showing the AnyOrElse operation

This is OrElse from Option<T> for sequences, with "empty" playing the role of None. It replaces the items.Any() ? items : fallback idiom, which enumerates items twice and breaks on a one-shot iterator. AnyOrElse enumerates the source exactly once: it yields the elements as they come and only consults the fallback once the source has finished without producing anything. The Func overload defers building the fallback until it is actually needed.

DefaultIfEmpty from LINQ is the special case with a one-element fallback.

Example

var recipients = explicitRecipients.AnyOrElse(() => LoadDefaultRecipients());

var menu = todaysSpecials.AnyOrElse(standardMenu);

Side effects and evaluation

LINQ pipelines are lazy and, by convention, pure. Sometimes you have to step outside that: run an action for the elements, look at what flows through the middle of a pipeline, force evaluation at a specific point, or make sure an expensive source is evaluated only once. These operations do that explicitly, so the place where the evaluation or the side effect happens is visible in the code rather than hidden in a foreach.

The distinction to keep in mind is eager versus lazy. ForEach, Materialize and Shuffle evaluate the input when they are called. Inspect, InspectEmpty and Memoize do nothing until the result is enumerated.

ForEach

Runs an action for every element, immediately.

Unit ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action)
Unit ForEach<TSource>(this IEnumerable<TSource> source, Func<TSource, Unit> action)

List<T> has a ForEach method; this one works on any IEnumerable<T>. It is eager: the whole sequence is enumerated during the call, which is the difference from Inspect. It returns Unit rather than void, so it can be used as the last expression of an expression-bodied member or a lambda that must return something.

ForEach is the end of a pipeline, not a step in it: everything before it should be pure, and the action is where the side effects happen. If the loop body builds up a value, that is an Aggregate, not a ForEach with a captured variable.

Example

// Before
foreach (var order in orders)
{
    Ship(order);
}

// After
orders.ForEach(Ship);

// As an expression-bodied member
public Unit ShipAll(IEnumerable<Order> orders)
    => orders.ForEach(Ship);

Inspect

Runs an action for every element as it passes through, and yields the element unchanged.

IEnumerable<TSource> Inspect<TSource>(this IEnumerable<TSource> source, Action<TSource> inspector)

Inspect is a Select that returns its input, with a side effect on the way. It is lazy: nothing runs until the result is enumerated, and the inspector then runs for each element at the moment that element is requested, interleaved with everything downstream. This makes it the tool for looking into the middle of a pipeline, for example to log, count or assert, without changing its structure.

The same name exists on Option<T>, Result<T> and Either<L, R> with the same meaning: observe, then hand back unchanged.

Example

return customers
    .Where(c => c.IsActive)
    .Inspect(c => logger.LogDebug("active customer {Id}", c.Id))
    .Select(c => c.Email);

Deferred execution

Because Inspect is deferred, where its side effect happens depends on who enumerates the result and when:

Enumerable.Range(1, 100)
    .Inspect(n => Console.WriteLine($"before where: {n}"))
    .Where(n => n % 2 == 0)
    .Inspect(n => Console.WriteLine($"after where: {n}"))
    .Take(2)
    .ToList();   // the side effects happen here

// before where: 1
// before where: 2
// after where: 2
// before where: 3
// before where: 4
// after where: 4

Only four elements were inspected, because Take(2) stopped asking. Replace ToList() with nothing, and nothing is printed at all. See the Microsoft documentation on deferred execution for the general rules.

InspectEmpty

Runs an action if the sequence turns out to be empty, and yields the elements unchanged otherwise.

IEnumerable<TSource> InspectEmpty<TSource>(this IEnumerable<TSource> source, Action inspector)
A marble diagram showing the InspectEmpty operation

This is the counterpart of Inspect for the case Inspect can never see: a sequence with no elements. Like Inspect it is lazy; the action runs during enumeration, at the point where the first element was requested and the source reported that there is none. It corresponds to InspectNone on Option<T>.

Example

var results = search(query)
    .InspectEmpty(() => logger.LogInformation("no results for {Query}", query))
    .Select(FormatResult);

Materialize

Evaluates a lazy sequence into a collection, unless it already is one.

IReadOnlyCollection<TSource> Materialize<TSource>(this IEnumerable<TSource> source)
IReadOnlyCollection<TSource> Materialize<TSource, TMaterialization>(this IEnumerable<TSource> source, Func<IEnumerable<TSource>, TMaterialization> materializer)
    where TMaterialization : IReadOnlyCollection<TSource>

A method that receives an IEnumerable<T> and needs to enumerate it twice, or needs its Count, usually calls ToList(). That copies the input even when the caller already passed a list. Materialize only does work when it has to: an IReadOnlyCollection<T> is returned as is, an IList<T> or ICollection<T> is wrapped without copying, and only a genuinely lazy sequence is evaluated, by default into an ImmutableList<T>.

The second overload lets you choose the collection for the lazy case, for example Materialize(Enumerable.ToArray) or Materialize(Enumerable.ToHashSet). Note that it is only used when the input is not already a collection.

The result is IReadOnlyCollection<T>, which is the honest type: you can count it and enumerate it any number of times. Because an existing collection is returned or wrapped rather than copied, the result is a live view: later changes to the caller's collection show through, and the wrapper itself cannot be used to modify the source. Materialize guarantees that a lazy sequence is evaluated only once, not that you hold a snapshot. Call ToList() or ToImmutableList() when you need one.

Example

void Report(IEnumerable<Order> orders)
{
    var materialized = orders.Materialize();

    Console.WriteLine($"{materialized.Count} orders");
    foreach (var order in materialized) { ... }   // no second evaluation of a lazy query
}

Memoize

Makes a sequence re-enumerable without evaluating it more than once: elements are produced lazily from the source the first time they are requested, and served from a buffer every time after.

IBuffer<TSource> Memoize<TSource>(this IEnumerable<TSource> source)
A marble diagram showing the Memoize operation

Materialize evaluates everything up front. Memoize sits between that and a plain lazy sequence: the source is still pulled one element at a time, so an infinite or expensive source is only evaluated as far as any consumer has looked, but every element is evaluated once, and a second enumeration replays the buffer instead of the source. This is what you want when a sequence is expensive to produce and consumed by several operators, or when a query is enumerated an unknown number of times.

The result is an IBuffer<T>, which is an IEnumerable<T> that is also IDisposable, because it owns the enumerator of the source. Dispose it when you are done, typically with using; enumerating a disposed buffer throws ObjectDisposedException. Memoizing a sequence that is already a list, a collection or a buffer does not copy it.

Several consumers may enumerate the buffer concurrently: the source is still pulled one element at a time and every element is produced once. The source is not touched before the first element is requested, and its enumerator is disposed as soon as it is exhausted.

The IAsyncEnumerable variant returns an IAsyncBuffer<T> with the same contract. The cancellation token passed to GetAsyncEnumerator, for example through WithCancellation, cancels that consumer's enumeration only.

Example

using var primes = Sequence.Successors(2, p => Option.Some(NextPrime(p))).Memoize();

var firstTen = primes.Take(10).ToList();
var below100 = primes.TakeWhile(p => p < 100).ToList();   // reuses the first ten, computes the rest
using var rows = ReadRowsFromDatabase().Memoize();

var total = rows.Sum(r => r.Amount);
var largest = rows.MaxByOrNone(r => r.Amount);   // no second database round trip

Shuffle

Returns the elements in random order.

IReadOnlyList<TSource> Shuffle<TSource>(this IEnumerable<TSource> source)
IReadOnlyList<TSource> Shuffle<TSource>(this IEnumerable<TSource> source, Random random)
A marble diagram showing the Shuffle operation

Shuffle is eager: it materializes the input and shuffles it in linear time with a Fisher–Yates shuffle, which gives every permutation the same probability. OrderBy(_ => random.Next()) is the common alternative; it is slower and, with a poorly chosen key, biased. Pass a Random to get reproducible results in tests or to use a seeded generator; its state is advanced by the call. Without one, the random numbers come from a shared generator (Random.Shared on .NET 6 and later, one generator per thread on older frameworks), so two shuffles in a row never repeat the way two new Random() instances created in the same tick do on .NET Framework.

.NET 10 added Shuffle to LINQ as a lazy operator. On that target the parameterless Shuffle() resolves to the BCL method, while the overload taking a Random stays Funcky's.

Example

var deck = cards.Shuffle();

var fixedOrder = cards.Shuffle(new Random(42));   // deterministic

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);

Sequence Constructors

LINQ is good at transforming sequences and poor at creating them. Enumerable.Range and Enumerable.Repeat are the only constructors it offers, so code that needs a one-element sequence ends up with new[] { x }, and code that needs an unfolding sequence ends up with a while loop and yield. The static class Funcky.Sequence fills that gap.

All members are static methods on Sequence, so they are called as Sequence.Return(...) and so on. Those that take a function or a lazy input are lazy themselves; Return and FromNullable are small materialized lists.

Since Funcky 3.8.0 the same constructors are also C# 14 static extension members of System.Linq.Enumerable, so they sit next to Enumerable.Range, Enumerable.Repeat and Enumerable.Empty where LINQ users look for them:

var naturals = Enumerable.Successors(1, n => n + 1);
var single = Enumerable.Return(42);
using var cycle = Enumerable.CycleRange(new[] { 1, 2, 3 });

Enumerable.Concat(a, b) with exactly two sequences binds to the BCL method, which does the same; with one sequence of sequences or three or more sequences it binds to Funcky's. The members are declared in Funcky.Extensions.EnumerableExtensions; on a compiler older than C# 14 they are plain static methods of that class, and Sequence remains the form to use.

ConstructorProduces
ReturnOne element, or exactly the elements given
FromNullableOne element, or nothing when it is null
SuccessorsEach element computed from the previous one; Aggregate in reverse
CycleThe same element forever
CycleRange, CycleMaterializedA whole sequence, repeated forever
RepeatRange, RepeatMaterializedA whole sequence, repeated n times
ConcatSeveral sequences one after the other

Return

Wraps one or more values in a sequence.

IReadOnlyList<TResult> Return<TResult>(TResult element)
IReadOnlyList<TResult> Return<TResult>(params TResult[] elements)

Return is the name monads use for "put a plain value into the monad", and IEnumerable<T> is a monad. Where you would write Option.Return(x) or Result.Return(x), you write Sequence.Return(x) for a sequence. It replaces new[] { x }, new List<T> { x } and Enumerable.Repeat(x, 1); the analyzer λ1001 flags the last one.

The params overload gives you a literal sequence of several elements, which is why the examples throughout this book use it. The result is an IReadOnlyList<T>, so it is materialized and can be enumerated any number of times. It is a read-only view over the array the compiler builds for the call: nothing is copied, and the view cannot be cast back to a mutable array. If you pass an existing array yourself, the view reflects later changes to that array.

Passing an array binds to the params overload, as with any params method: Sequence.Return(array) is a sequence of the array's elements. To get a sequence whose single element is the array, name the type argument, Sequence.Return<int[]>(array).

IEnumerable<string> greeting = Sequence.Return("hello");

var suits = Sequence.Return("♠", "♣", "♥", "♦");

// Prepend a header to a lazy sequence without materializing it
var report = Sequence.Return(header).Concat(rows);

FromNullable

A sequence of one element, or an empty sequence if the element is null.

IEnumerable<TResult> FromNullable<TResult>(TResult? element) where TResult : class
IEnumerable<TResult> FromNullable<TResult>(TResult? element) where TResult : struct

This is Option.FromNullable followed by ToEnumerable, in one step. It is useful where a null should simply contribute nothing, for example inside a SelectMany:

var inner = exceptions.SelectMany(e => Sequence.FromNullable(e.InnerException));

Successors

Builds a sequence by applying a function to the previous element, starting from a given first element. It is the inverse of Aggregate: Aggregate folds a sequence into a value, Successors unfolds a value into a sequence.

IEnumerable<TResult> Successors<TResult>(TResult first, Func<TResult, TResult> successor)
IEnumerable<TResult> Successors<TResult>(TResult first, Func<TResult, Option<TResult>> successor)
IEnumerable<TResult> Successors<TResult>(Option<TResult> first, Func<TResult, TResult> successor)
IEnumerable<TResult> Successors<TResult>(Option<TResult> first, Func<TResult, Option<TResult>> successor)
A marble diagram showing the Successors operation

With a successor returning T, the sequence is infinite, and you must Take, TakeWhile or otherwise stop it. With a successor returning Option<T>, the sequence ends at the first None; the element that produced the None is still included. The overloads taking an Option<T> as the first element produce an empty sequence when it is None, which is convenient when the start itself comes from an …OrNone lookup.

The first element is always included. Skip(1) if you only want what follows it.

// All non-negative integers
Sequence.Successors(0, n => n + 1);

// Powers of two that fit in an int
Sequence.Successors(1, n => n <= int.MaxValue / 2 ? Option.Some(n * 2) : Option<int>.None);

// The chain of inner exceptions
Sequence.Successors(exception, e => Option.FromNullable(e.InnerException));

// Walking up a directory tree
Sequence.Successors(directory, d => Option.FromNullable(d.Parent));

// Fibonacci, by carrying a pair of state and projecting it away
Sequence.Successors((0, 1), p => (p.Item2, p.Item1 + p.Item2)).Select(p => p.Item1);

// All days of a year, see the calendar case study
Sequence.Successors(JanuaryFirst(year), NextDay).TakeWhile(day => day.Year == year);

The last example is a pattern worth recognising: anything you would express with a for loop whose state is one value, for (var x = first; ...; x = next(x)), is a Successors.

Cycle

Repeats a single element forever.

IEnumerable<TResult> Cycle<TResult>(TResult element)

This is Enumerable.Repeat without a count, or Successors(element, Identity). Typical uses are zipping a constant against another sequence, or a placeholder stream to be cut with Take.

var padded = words.ZipLongest(Sequence.Cycle("").Take(10), pair => pair.Match(
    left: w => w, right: p => p, both: (w, _) => w));

CycleRange and CycleMaterialized

Repeat a whole sequence forever: after the last element, the first comes again.

IBuffer<TSource> CycleRange<TSource>(IEnumerable<TSource> source)
IEnumerable<TSource> CycleMaterialized<TSource>(IReadOnlyCollection<TSource> source)
A marble diagram showing the CycleRange operation

The two differ in what they do with the source. CycleRange accepts any IEnumerable<T> and must remember the elements to replay them, so it buffers the source as it is enumerated the first time, exactly like Memoize, and therefore returns an IBuffer<T> that should be disposed. CycleMaterialized takes a collection that is already in memory and simply reads it again and again, with no buffer and nothing to dispose. Use CycleMaterialized whenever you have a list, and CycleRange for a lazy or one-shot source.

Both throw an InvalidOperationException when the source turns out to be empty, because an empty cycle can neither end nor produce anything. For CycleRange this happens on enumeration, once the source has been read to its end.

var colors = Sequence.Return("red", "green", "blue");

var striped = rows.Zip(Sequence.CycleMaterialized(colors), (row, color) => (row, color));

using var schedule = Sequence.CycleRange(ReadShiftsFromFile());
var nextTen = schedule.Take(10).ToList();

RepeatRange and RepeatMaterialized

Repeat a whole sequence a fixed number of times.

IBuffer<TSource> RepeatRange<TSource>(IEnumerable<TSource> source, int count)
IEnumerable<TSource> RepeatMaterialized<TSource>(IReadOnlyCollection<TSource> source, int count)

These are the finite versions of CycleRange and CycleMaterialized, with the same division of labour: RepeatRange buffers a lazy source and returns an IBuffer<T>, RepeatMaterialized reads a collection repeatedly. Unlike the cycle variants, an empty source is not an error: repeating nothing n times is nothing. A count of zero yields an empty sequence.

Sequence.RepeatMaterialized(Sequence.Return(1, 2), 3);
// [1, 2, 1, 2, 1, 2]

var border = Sequence.RepeatMaterialized(Sequence.Return('-'), width).ConcatToString();

Concat

Concatenates any number of sequences.

IEnumerable<TSource> Concat<TSource>(params IEnumerable<TSource>[] sources)
IEnumerable<TSource> Concat<TSource>(IEnumerable<IEnumerable<TSource>> sources)
A marble diagram showing the Concat operation

LINQ's Concat takes exactly two sequences, so joining three reads a.Concat(b).Concat(c). Sequence.Concat(a, b, c) says the same thing once. It is lazy, enumerates the inputs in order, and is the same operation as Flatten on the sequence of sequences.

var all = Sequence.Concat(header, body, footer);

Asynchronous counterparts

AsyncSequence provides Return, FromNullable, Successors, Cycle, CycleRange, RepeatRange and Concat for IAsyncEnumerable<T>, in Funcky itself on .NET 10 and in the Funcky.Async package on older targets. See Funcky.Async and IAsyncEnumerable.

Funcky.Async and IAsyncEnumerable

Everything in this book so far was synchronous. This chapter covers the asynchronous side of Funcky: the IAsyncEnumerable<T> counterparts of the IEnumerable extensions, the AsyncSequence constructors, RetryAsync, and what it means to await an Option<Task<T>>.

Where the APIs live

The asynchronous APIs have two homes, and which one you use depends only on your target framework.

  • .NET 10 and newer: they are part of the Funcky package itself. IAsyncEnumerable<T> LINQ (System.Linq.AsyncEnumerable) is in the base class library since .NET 10, so Funcky can build on it without an extra dependency. You need no second package.
  • Older frameworks (netstandard2.0 up to .NET 9): they are in the Funcky.Async package, which compiles the same sources against System.Linq.Async (versions 5 and 6).

The net10.0 asset of Funcky.Async contains nothing but type forwards to Funcky. A project that referenced Funcky.Async on .NET 8 and then retargets to .NET 10 therefore keeps the package reference working, and can drop it whenever convenient. The forwards do not make the two sides source compatible, though: where the base class library already provides an operator, Funcky defers to it, and two of them differ from the Funcky.Async shape.

  • AdjacentGroupBy returns IAsyncEnumerable<IAsyncGrouping<TKey, T>> in Funcky.Async, since System.Linq.Async has no synchronous groupings, and IAsyncEnumerable<IGrouping<TKey, T>> on .NET 10. Code that treats a group as a sequence, await grouping.CountAsync() for example, becomes grouping.Count() after retargeting.
  • Chunk(size) without a result selector is the base class library's on .NET 10 and yields T[] instead of IReadOnlyList<T>. The overloads with a result selector are Funcky's on both sides and keep IReadOnlyList<T>.

Namespaces and type names are otherwise the same on both sides: the extension methods are in Funcky.Extensions.AsyncEnumerableExtensions, the constructors in Funcky.AsyncSequence, retrying in Funcky.AsyncFunctional, and the Option awaiters in Funcky.Monads.OptionAsyncExtensions. With FunckyImplicitUsings on, the first three namespaces are imported for you; AsyncFunctional is not imported statically, so RetryAsync is called as AsyncFunctional.RetryAsync unless you add using static Funcky.AsyncFunctional;.

The mental model: the .NET 10 vocabulary

System.Linq.AsyncEnumerable has one rule for operators that take a callback: there is one name per operator, and the asynchronous form of the callback receives the cancellation token of the enumeration as its last parameter. Select takes either a Func<T, TResult> or a Func<T, CancellationToken, ValueTask<TResult>>; there is no SelectAwait. Funcky follows the same rule, on .NET 10 and in Funcky.Async alike, since 3.8.0:

IAsyncEnumerable<Order> orders = …;

// Synchronous delegate: same as on IEnumerable<T>.
var logged = orders.Inspect(order => logger.LogInformation("{Id}", order.Id));

// Asynchronous delegate: (item, cancellationToken) => ValueTask.
var audited = orders.Inspect(async (order, cancellationToken) => await audit.RecordAsync(order, cancellationToken));

// You do not need the token? Discard it.
var saved = orders.Inspect(async (order, _) => await repository.SaveAsync(order));

The token the delegate receives is the one passed to GetAsyncEnumerator, typically through await foreach (var x in xs.WithCancellation(token)), or the cancellationToken argument of a terminal operator such as FirstOrNoneAsync(predicate, cancellationToken). Passing it on to the asynchronous call inside the delegate is what makes cancelling the enumeration also cancel the work in progress.

Always write the two-parameter form for an asynchronous delegate, even when you discard the token. The one-parameter async order => … has no asynchronous overload to bind to once the obsolete …Await methods are gone, and C# would happily convert it to the Action<T> overload as an async void lambda. The enumeration then continues without awaiting the callback, and an exception thrown by it does not fail the enumeration: it is posted to the captured synchronization context or, without one, surfaces as an unhandled exception that can take the process down.

The operator families

Most operators marked ⁂ in the IEnumerable Extensions table exist on IAsyncEnumerable<T> with the same name and the same behaviour. Operators that return a sequence keep their name (Pairwise, Merge, WithIndex, …); operators that return a single value get the Async suffix and return a ValueTask (ElementAtOrNoneAsync, AggregateOrNoneAsync, MaterializeAsync, …). The operators below are the ones that take a delegate and therefore come in a synchronous and an asynchronous form.

Lazy operators with an asynchronous delegate

These return an IAsyncEnumerable<T> and do nothing until enumerated. The asynchronous delegate is the synchronous one plus a CancellationToken parameter, returning a ValueTask.

OperatorSynchronous delegateAsynchronous delegate
AdjacentGroupByFunc<T, TKey> key selector (plus optional element and result selectors)Func<T, CancellationToken, ValueTask<TKey>>, same for the element and result selectors
ChunkFunc<IReadOnlyList<T>, TResult> result selectorFunc<IReadOnlyList<T>, CancellationToken, ValueTask<TResult>>
ExclusiveScanFunc<TAccumulate, T, TAccumulate>Func<TAccumulate, T, CancellationToken, ValueTask<TAccumulate>>
InclusiveScanFunc<TAccumulate, T, TAccumulate>Func<TAccumulate, T, CancellationToken, ValueTask<TAccumulate>>
InspectAction<T>Func<T, CancellationToken, ValueTask>
WhereSelectFunc<T, Option<TResult>>, or with an index Func<T, int, Option<TResult>>Func<T, CancellationToken, ValueTask<Option<TResult>>>, or Func<T, int, CancellationToken, ValueTask<Option<TResult>>>
// Look up each id, skip the ones that are not found, with one name for the operator.
IAsyncEnumerable<Customer> customers = ids
    .WhereSelect(async (id, cancellationToken) => await customerStore.FindOrNoneAsync(id, cancellationToken));

// Running total with an asynchronous exchange rate lookup.
IAsyncEnumerable<decimal> runningTotal = payments
    .InclusiveScan(0m, async (total, payment, cancellationToken) => total + await ToChf(payment, cancellationToken));

For AdjacentGroupBy and Chunk note what the asynchronous delegate applies to: the key or result selector. The grouping and chunking itself is the same as in the synchronous operator.

Terminal aggregations with an asynchronous delegate

These enumerate the whole input (or as much as they need) and return a ValueTask. All of them take a trailing CancellationToken cancellationToken = default, and the asynchronous delegate receives that same token.

OperatorReturnsAsynchronous delegate
AverageOrNoneAsyncValueTask<Option<double>> (or float/decimal)selector Func<T, CancellationToken, ValueTask<int>> and the other numeric types, also returning Option<…>
FirstOrNoneAsyncValueTask<Option<T>>predicate Func<T, CancellationToken, ValueTask<bool>>
LastOrNoneAsyncValueTask<Option<T>>predicate Func<T, CancellationToken, ValueTask<bool>>
SingleOrNoneAsyncValueTask<Option<T>>predicate Func<T, CancellationToken, ValueTask<bool>>
MaxOrNoneAsyncValueTask<Option<TResult>>selector Func<T, CancellationToken, ValueTask<TResult>> or …ValueTask<Option<TResult>>>
MinOrNoneAsyncValueTask<Option<TResult>>selector Func<T, CancellationToken, ValueTask<TResult>> or …ValueTask<Option<TResult>>>
NoneAsyncValueTask<bool>predicate Func<T, CancellationToken, ValueTask<bool>>
PartitionAsyncValueTask<Partitions<T>> or ValueTask<TResult>predicate Func<T, CancellationToken, ValueTask<bool>>, optional result selector Func<IReadOnlyList<T>, IReadOnlyList<T>, CancellationToken, ValueTask<TResult>>
Option<Order> firstOpen = await orders.FirstOrNoneAsync(
    async (order, cancellationToken) => await IsOpenAsync(order, cancellationToken),
    cancellationToken);

var (large, small) = await orders.PartitionAsync(async (order, _) => await IsLargeAsync(order));

PartitionAsync also has the Either- and Result-based forms of Partition. Their delegates, the Either selector and the result selectors, are synchronous only, so these overloads never had an …Await form and were not affected by the renaming.

Migrating from the …Await names

Before 3.8.0 Funcky used the vocabulary of System.Linq.Async, which has three names per operator: Inspect for a synchronous delegate, InspectAwait for an asynchronous one, and InspectAwaitWithCancellation for an asynchronous one that receives the token. The terminal aggregations followed with FirstOrNoneAwaitAsync and FirstOrNoneAwaitWithCancellationAsync. Since 3.8.0 these overloads are [Obsolete], they stay a warning (CS0618) for the whole 3.x line, and they are removed in Funcky 4.0.0 (#981).

The mapping is mechanical:

ObsoleteReplacementChange to the delegate
xs.InspectAwaitWithCancellation(f)xs.Inspect(f)none, the delegate already takes the token
xs.InspectAwait(x => …)xs.Inspect((x, _) => …)add a discarded token parameter
xs.FirstOrNoneAwaitWithCancellationAsync(f)xs.FirstOrNoneAsync(f)none
xs.FirstOrNoneAwaitAsync(x => …)xs.FirstOrNoneAsync((x, _) => …)add a discarded token parameter

The same applies to the other operators of the two families above. The obsolete message on each method spells out its own replacement.

Apply the code fix solution-wide

Do not do this by hand. Funcky.Analyzers 2.1.0 ships a code fix for CS0618 on these methods, with fix-all support. In the IDE, open the lightbulb on any of the warnings, choose the fix, and apply it to the solution (the fix-all scope selector offers document, project and solution). One pass migrates every call site. The fix:

  • renames …AwaitWithCancellation and …AwaitWithCancellationAsync calls and leaves the delegate alone;
  • renames …Await and …AwaitAsync calls and appends the discarded token parameter to every lambda argument, x => … becomes (x, _) => …;
  • turns a method group or a delegate value into a forwarding lambda, because a Func<T, ValueTask> cannot be passed where a Func<T, CancellationToken, ValueTask> is expected: xs.InspectAwait(Log) becomes xs.Inspect((x, _) => Log(x)), and xs.InspectAwait(this.inspector) becomes xs.Inspect((x, _) => this.inspector(x)).

A call whose delegate argument is produced by an expression that would change meaning when evaluated per element, for example xs.InspectAwait(CreateInspector()), gets no fix; the forwarding lambda would call CreateInspector() for every item. Rewrite those few by hand, by storing the delegate in a local first. Anonymous methods in the delegate (x) { … } syntax are not rewritten either; convert them to lambdas.

Note that RetryAwaitAsync in AsyncFunctional is not part of this deprecation. It is a different method that happens to share the Await infix, and it stays.

AsyncSequence

AsyncSequence is the asynchronous twin of the Sequence constructors. It produces an IAsyncEnumerable<T> where Sequence produces an IEnumerable<T>.

ConstructorDescription
Return(element), Return(params elements)A sequence of the given elements
FromNullable(element)One element, or empty when the reference or Nullable<T> is null
Successors(first, successor)Each element computed from the previous one by an asynchronous Func<T, ValueTask<T>>; endless, or finite with a Func<T, ValueTask<Option<T>>> successor, which stops at the first None. first may itself be an Option<T>.
Cycle(element)The same element forever
CycleRange(source)The elements of an IAsyncEnumerable<T> repeated forever
RepeatRange(source, count)The elements of an IAsyncEnumerable<T> repeated count times
Concat(params sources), Concat(IAsyncEnumerable<IAsyncEnumerable<T>>), Concat(IEnumerable<IAsyncEnumerable<T>>), Concat(IAsyncEnumerable<IEnumerable<T>>)One sequence after the other
// Follow a paged API until it reports no next page.
IAsyncEnumerable<Page> pages = AsyncSequence.Successors(
    firstPage,
    async page => await client.NextPageOrNoneAsync(page.ContinuationToken));

CycleRange and RepeatRange return an IAsyncBuffer<T>, which is an IAsyncEnumerable<T> that is also IAsyncDisposable: the source is enumerated once and buffered, and disposing the buffer disposes the source. The same interface is returned by the asynchronous Memoize. Use await using.

The successor delegate of Successors does not receive a cancellation token; the generator checks the token of the enumeration between two elements instead.

RetryAsync

Retry blocks the thread with Thread.Sleep between attempts. AsyncFunctional offers the same two families awaiting Task.Delay instead, with a CancellationToken that is checked before every attempt and passed to Task.Delay, so a cancelled token stops the retrying even when the policy would allow more.

The Option-based family, where None means "not yet":

ValueTask<TResult> RetryAsync<TResult>(Func<ValueTask<Option<TResult>>> producer, CancellationToken cancellationToken = default)
ValueTask<TResult> RetryAsync<TResult>(Func<CancellationToken, ValueTask<Option<TResult>>> producer, CancellationToken cancellationToken = default)
ValueTask<Option<TResult>> RetryAsync<TResult>(Func<ValueTask<Option<TResult>>> producer, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask<Option<TResult>> RetryAsync<TResult>(Func<CancellationToken, ValueTask<Option<TResult>>> producer, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)

The exception-based family, where shouldRetry decides which exceptions are worth another attempt. The synchronous producers are named RetryAsync, the asynchronous ones RetryAwaitAsync:

ValueTask<TResult> RetryAsync<TResult>(Func<TResult> producer, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask RetryAsync(Action action, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask<TResult> RetryAwaitAsync<TResult>(Func<ValueTask<TResult>> producer, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask<TResult> RetryAwaitAsync<TResult>(Func<CancellationToken, ValueTask<TResult>> producer, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask RetryAwaitAsync(Func<ValueTask> action, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)
ValueTask RetryAwaitAsync(Func<CancellationToken, ValueTask> action, Func<Exception, bool> shouldRetry, IRetryPolicy retryPolicy, CancellationToken cancellationToken = default)

Prefer the overloads whose producer receives the token: they can abort an attempt that is already in progress, not only refrain from starting the next one.

Option<Response> response = await AsyncFunctional.RetryAsync(
    async token => await TrySendAsync(request, token),             // Func<CancellationToken, ValueTask<Option<Response>>>
    new ExponentialBackOffRetryPolicy(maxRetries: 5, firstDelay: TimeSpan.FromMilliseconds(200)),
    cancellationToken);

The retry policies are the same IRetryPolicy implementations as for the synchronous Retry, see Retry policies.

Awaiting an Option of a Task

An Option<Task<T>> is a common shape: an operation that may or may not have been started.

Option<Task<User>> pendingUser = cache.GetValueOrNone(id).Select(entry => entry.LoadAsync());

Getting an Option<User> out of it the long way means matching on the option, awaiting in the Some branch and wrapping the result again. Funcky lets you await the option directly:

Option<User> user = await pendingUser;

None completes immediately with None; Some(task) awaits the task and wraps its result in Some. The awaiters exist for Option<Task>, Option<Task<T>>, Option<ValueTask> and Option<ValueTask<T>>; the non-generic ones complete with no value. ConfigureAwait is supported as well, so library code can write

Option<User> user = await pendingUser.ConfigureAwait(false);

The same conversion is available as a method, for use in a query or when the result is passed on without awaiting: option.Sequence() turns an Option<Task<T>> into a Task<Option<T>>, and option.Traverse(item => LoadAsync(item)) does the Select and Sequence in one step. Both exist for Task, ValueTask and IAsyncEnumerable, and the Traverse/Sequence pairs for Result and Either are in ResultAsyncExtensions and EitherAsyncExtensions. Finally, option.ToAsyncEnumerable() yields the value as a one-element IAsyncEnumerable<T>, or nothing.

String Extensions

IndexOf

The classical IndexOf methods provide a special form of error handling by returning -1 when nothing is found. This is very cumbersome and a potential footgun, since you're not forced to check the return value.

Funcky offers extension methods on string for each overload of IndexOf, IndexOfAny, LastIndexOf, and LastIndexOfAny. The extension methods follow the simple convention of being suffixed with OrNone.

Option<string> ParseKey(string input)
    => input.IndexOfOrNone('[')
         .Select(startIndex => ParseKeyWithMultipleParts(input, startIndex))
         .GetOrElse(() => ParseRegularKey(input));

Example usage of IndexOfOrNone

Chunk and SlidingWindow

The sequence operations Chunk and SlidingWindow exist on string too, producing strings instead of lists of characters:

IEnumerable<string> Chunk(this string source, int size)
IEnumerable<string> SlidingWindow(this string source, int width)
"abcdefg".Chunk(3);          // ["abc", "def", "g"]
"abcde".SlidingWindow(3);    // ["abc", "bcd", "cde"]

The rules are the same as for sequences: the last chunk may be shorter, only full windows are produced, and a size or width of zero or less throws an ArgumentOutOfRangeException.

SplitLazy

string.Split allocates an array with all parts up front. SplitLazy produces the same parts one at a time, which matters when the string is large and you only need the first few parts, or when the parts are consumed by a streaming pipeline anyway.

IEnumerable<string> SplitLazy(this string text, char separator)
IEnumerable<string> SplitLazy(this string text, params char[] separators)
IEnumerable<string> SplitLazy(this string text, string separator)
IEnumerable<string> SplitLazy(this string text, params string[] separators)
var firstField = line.SplitLazy(',').FirstOrNone();

SplitLines

Splits text into lines, treating \n, \r and \r\n each as a line break, lazily.

IEnumerable<string> SplitLines(this string text)

A trailing line break does not produce an empty last line, and the empty string has no lines.

"first\r\nsecond\nthird\n".SplitLines();   // ["first", "second", "third"]

Number Extensions

InRange

Checking whether a number lies within an interval is usually written as two comparisons, and whether each end of the interval is included is only visible in the choice between < and <=. InRange makes the kind of each boundary part of the call, so it reads like interval notation:

bool InRange<TFrom, TTo>(this int number, int from, int to)
    where TFrom : IIntervalBoundary
    where TTo : IIntervalBoundary

The type arguments are Including or Excluding, one for each boundary:

12.InRange<Including, Excluding>(0, 20);   // true:  0 <= 12 && 12 < 20
20.InRange<Including, Excluding>(0, 20);   // false: the upper boundary is excluded
 0.InRange<Excluding, Excluding>(0, 20);   // false: the lower boundary is excluded
 5.InRange<Including, Including>(5, 5);    // true:  the only interval containing a single value

The boundaries may be given in any order, but each boundary kind stays attached to its corresponding argument; 12.InRange<Excluding, Including>(20, 0) checks the same interval as 12.InRange<Including, Excluding>(0, 20).

InRange exists on every numeric type of the base class library (int, long, double, decimal, Int128, Half, BigInteger, char, …). It requires .NET 7 or newer, because the implementation is built on generic math. A custom type implementing IComparisonOperators<T, T, bool> can use the static method directly:

NumberExtensions.InRange<Money, Including, Excluding>(amount, Money.Zero, limit);

The Including and Excluding types carry no state. They implement IIntervalBoundary through static abstract members, so the comparison is resolved at compile time and the check costs the same as writing it by hand.

Stream Extensions

System.IO.Stream is an abstraction over many kinds of streams, and not every stream supports every operation. A NetworkStream cannot seek, so its Length and Position throw a NotSupportedException; a MemoryStream cannot time out, so ReadTimeout throws an InvalidOperationException. The BCL's answer is a set of Can… flags you are expected to check before touching the property.

Funcky folds the flag and the property into one Option-returning call:

Property or methodFunckyNone when
LengthGetLengthOrNone()CanSeek is false
PositionGetPositionOrNone()CanSeek is false
ReadTimeoutGetReadTimeoutOrNone()CanTimeout is false
WriteTimeoutGetWriteTimeoutOrNone()CanTimeout is false
ReadByte(), which returns -1ReadByteOrNone()the end of the stream is reached
Option<long> GetLengthOrNone(this Stream stream)
Option<long> GetPositionOrNone(this Stream stream)
Option<int> GetReadTimeoutOrNone(this Stream stream)
Option<int> GetWriteTimeoutOrNone(this Stream stream)
Option<byte> ReadByteOrNone(this Stream stream)

The first four are implemented by catching the specific exception the BCL documents for the unsupported case. Other exceptions, such as ObjectDisposedException on a closed stream or an IOException, are not caught and still propagate.

ReadByteOrNone removes the -1 sentinel from ReadByte, which returns an int only so that it can signal the end of the stream. The result is a real byte.

Example

string DescribeProgress(Stream stream)
    => (from position in stream.GetPositionOrNone()
        from length in stream.GetLengthOrNone()
        select $"{position} of {length} bytes")
        .GetOrElse("position unknown");
IEnumerable<byte> Bytes(Stream stream)
    => Sequence.Successors(stream.ReadByteOrNone(), _ => stream.ReadByteOrNone());

See The TryVerb-pattern for the same idea across the rest of the BCL.

Nullable Member Extensions

A handful of base class library members return null as a regular outcome rather than as an error: a type without a base type, an assembly without an entry point, a directory at the root of its drive. Funcky offers each of them as an Option through a C# 14 extension property, so the absence is a None rather than a null check.

Option<Type> baseType = typeof(Foo).BaseTypeOrNone;                 // None for object, interfaces and pointer types
Option<Type> declaringType = typeof(Foo).DeclaringTypeOrNone;       // None for a type that is not nested
Option<MethodInfo> entryPoint = assembly.EntryPointOrNone;          // None for a class library
Option<Exception> cause = exception.InnerExceptionOrNone;           // None without an inner exception
Option<DirectoryInfo> parent = directory.ParentOrNone;              // None for a root directory
Option<Uri> location = response.Headers.LocationOrNone;             // None without a Location header
PropertyOnWraps
BaseTypeOrNoneTypeType.BaseType
DeclaringTypeOrNoneMemberInfo (and therefore Type)MemberInfo.DeclaringType
EntryPointOrNoneAssemblyAssembly.EntryPoint
InnerExceptionOrNoneExceptionException.InnerException
ParentOrNoneDirectoryInfoDirectoryInfo.Parent
LocationOrNoneHttpResponseHeadersHttpResponseHeaders.Location

The properties need the C# 14 compiler. On an older compiler they are available as the static methods the compiler lowers them to, TypeExtensions.get_BaseTypeOrNone(type), which is rarely worth it over Option.FromNullable(type.BaseType). Members that return null from a method call, such as Environment.GetEnvironmentVariable, are out of scope here; wrap them with Option.FromNullable.

Testing with xUnit

A test that receives an Option<T> has the same question as any other code: how do I get the value out? In production code the answer is Select, Match and friends. In a test the answer is an assertion, because a test wants to fail when the case is wrong. Funcky.Xunit provides those assertions for Option<T>, Result<T> and Either<L, R>, in the same shape as xunit's own Assert.Single or Assert.IsType: assert the case, get the content back.

Which package

xUnit.net versionPackage
2.xFuncky.Xunit
v3, 1.x through 4.xFuncky.Xunit.v3

Both packages share the assertions below and differ only in the theory-data helpers at the end of this chapter. The assertions are static extension members of xunit's Assert, declared in Funcky.Extensions.AssertExtensions, so they appear in the same completion list as Assert.Equal:

using Funcky.Extensions;

Assert.Some(42, FindUser(id).Select(user => user.Age));

Extension members need the C# 14 compiler (.NET 10 SDK). Older compilers use the same assertions as methods of the Funcky.FunctionalAssert class, FunctionalAssert.Some(42, option). The two forms are the same code; the Assert members are the implementation and FunctionalAssert forwards to them. If both are available, λ1203 asks for the Assert form and comes with a code fix.

xUnit.net 3.x versions

xUnit.net v3 has had three major releases since 1.0, each breaking binary compatibility with the previous one for extensibility authors. Funcky.Xunit.v3 is unaffected: it uses only Assert, XunitException, ArgumentFormatter and the serializer interfaces, none of which changed. The package is compiled against xunit.v3.assert and xunit.v3.extensibility.core 1.0.1 and declares the range [1.0.1, 5), so NuGet takes whichever version your test project references.

What does change between the xunit versions is the test host and the runner, not Funcky. In particular, with the .NET 10 SDK, xunit.v3 4.x no longer runs under VSTest unless you opt in, which also affects the Azure DevOps DotNetCoreCLI@2 task.

xunit.v3Funcky.Xunit.v3Test project runs ondotnet test with the .NET 10 SDK
1.x1.0.0 and later.NET 6+, .NET Framework 4.7.2+VSTest (xunit.runner.visualstudio) or Microsoft.Testing.Platform v1
2.x1.1.0 and later, expected to work.NET 8+, .NET Framework 4.7.2+VSTest or Microsoft.Testing.Platform v1
3.x1.1.0 and later, expected to work.NET 8+, .NET Framework 4.7.2+VSTest or Microsoft.Testing.Platform v1
4.x1.1.0 and later, verified.NET 8+, .NET Framework 4.7.2+Microsoft.Testing.Platform v2; VSTest only with the xunit.v3.*.mtp-off packages

"Verified" means Funcky's own test suite runs green against that version. "Expected to work" means nothing in the xunit release notes touches what Funcky uses, but the suite is not run against it yet (#1015 tracks a matrix build). The test host column is xunit's minimum; Funcky.Xunit.v3 itself targets netstandard2.0 and net6.0.

If dotnet test on the .NET 10 SDK stops with "Testing with VSTest target is no longer supported by Microsoft.Testing.Platform", either opt into Microsoft.Testing.Platform in global.json ("test": { "runner": "Microsoft.Testing.Platform" }) or reference the mtp-off variants of the xunit packages. A v3 test project is an executable, so dotnet run --project works whichever runner the SDK prefers.

The assertions

Each monad gets one assertion per case. The assertion for the case that carries a value comes in two overloads: one that takes the expected value and compares it, and one that returns the value so you can continue asserting on it.

MonadCaseExpected-value overloadReturning overload
Option<T>SomeAssert.Some(expected, option)T Assert.Some(option)
Option<T>NoneAssert.None(option)
Result<T>OkAssert.Ok(expected, result)T Assert.Ok(result)
Result<T>ErrorException Assert.Error(result)
Result<T>ErrorTException Assert.Error<TException, T>(result)
Either<L, R>LeftAssert.Left(expected, either)L Assert.Left(either)
Either<L, R>RightAssert.Right(expected, either)R Assert.Right(either)
[Fact]
public void ParsesTheAnswer()
    => Assert.Some(42, "42".ParseInt32OrNone());

[Fact]
public void RejectsText()
    => Assert.None("forty-two".ParseInt32OrNone());

[Fact]
public void PlacesTheOrder()
{
    // The returning overload: unwrap, then assert on the content.
    var order = Assert.Ok(PlaceOrder(customerId, productId, quantity: 3));
    Assert.Equal(3, order.Quantity);
    Assert.NotEqual(default, order.Id);
}

[Fact]
public void RejectsUnknownCustomers()
{
    // Error<TException, T> asserts the exact exception type and returns it typed,
    // the analogue of Assert.Throws<T>.
    var exception = Assert.Error<CustomerNotFoundException, Order>(PlaceOrder("nobody", productId, quantity: 3));
    Assert.Equal("nobody", exception.CustomerId);
}

[Fact]
public void ValidatesTheInput()
    => Assert.Left("quantity must be positive", Validate(quantity: 0));

Assert.Error(result) without type arguments accepts any exception and returns it as Exception. Assert.Error<TException, T>(result) requires the exception to be of exactly TException, not a subtype, which is the same rule Assert.Throws<T> applies to thrown exceptions. The second type argument is the T of the Result<T>; C# cannot infer it when only one type argument is given, so both are spelled out.

The expected value is compared with Assert.Equal, so the same equality xunit uses everywhere applies, including its structural comparison of collections.

Prefer the expected-value overload

When the test only checks for equality, do not unwrap and compare in two steps:

// Two assertions, two places to read.
Assert.Equal(42, Assert.Some(option));

// One assertion, and a failure message that shows the whole option.
Assert.Some(42, option);

λ1101 flags the first form and rewrites it to the second. The single call is not only shorter, it also fails better. Both forms report None as an assertion failure, but only the single call knows what was expected and prints Expected: Some(42) where the two-step form can only say Some(...). And when the option is Some(41), the two-step form fails in Assert.Equal with 41 against 42 and no trace of the monad, while the single call reports Expected: Some(42) and Actual: Some(41).

How a failure reads

A failed assertion throws an XunitException, so the test runner shows it like any other failed assertion. The message names the assertion and prints both sides in the notation the monads use in ToString:

FunctionalAssert.Some() Failure: Values differ
Expected: Some(10)
Actual:   None
FunctionalAssert.Ok() Failure: Values differ
Expected: Ok(10)
Actual:   Error(System.InvalidOperationException: "boom")
FunctionalAssert.Error() Failure: Values differ
Expected: Error(ArgumentException)
Actual:   Error(InvalidOperationException)

The returning overloads cannot know the expected content and print Some(...), Ok(...), Error(...), Left(...) or Right(...) as the expected side. The label says FunctionalAssert in both forms, so a message looks the same whichever receiver the test used.

On .NET 6 and later the assertion's own frames are hidden from the stack trace with [StackTraceHidden], so the first line of the trace is the test that failed. On older frameworks a rethrow erases the internal frames of the monad and the lambdas, but the assertion method itself remains the first frame, with the test right below it.

Why there is no Assert.True(option.IsSome)

The introduction explains that Funcky's monads have no IsSome, no HasValue and no Value. Tests are the place where that is most tempting to miss, because a test legitimately wants to look inside. These assertions are the answer: they are the test-side way to look inside, and they are better than a boolean check would be.

Assert.True(option.IsSome) would tell you that the option was empty and nothing else. Assert.Some(42, option) tells you that it was None when Some(42) was expected, or that it was Some(41). And because the returning overload hands you the content, there is never a reason to read it a second time through a Value property; the assertion already proved the case, and the type system carries that proof to the next line.

Theory data

ToTheoryData (xUnit.net 2.x)

xunit 2.x theories take their rows from a TheoryData<…>, which is filled with repeated Add calls. Funcky.Xunit adds ToTheoryData on any IEnumerable<T>, and on sequences of Tuple and ValueTuple with up to ten components, so the rows can come from a LINQ query:

[Theory]
[MemberData(nameof(SquaresUpToTen))]
public void SquaresArePositive(int number, int square)
    => Assert.True(square >= number);

public static TheoryData<int, int> SquaresUpToTen()
    => Enumerable.Range(0, 10)
        .Select(number => (number, number * number))
        .ToTheoryData();

Each tuple becomes one row, each component one parameter. The extension lives in Funcky.Extensions and is not part of Funcky.Xunit.v3: xunit 3.x TheoryData accepts a collection expression, TheoryData<int> rows = [1, 2, 3];, which covers the same need.

Option, Either and Unit as theory data (xUnit.net 3.x)

xunit 3.x runs a theory with any data, but only rows it can serialize are discovered individually: each shows up as its own test in the runner and can be rerun on its own. A row with a value xunit cannot serialize collapses the whole theory into a single test case. Funcky.Xunit.v3 ships serializers for Option<T>, Either<L, R> and Unit, so these types work as theory parameters without losing per-row discovery:

[Theory]
[MemberData(nameof(Options))]
public void SelectingTheIdentityChangesNothing(Option<int> option)
    => Assert.Equal(option, option.Select(value => value));

public static TheoryData<Option<int>> Options()
    => [Option<int>.None, Option.Some(10), Option.Some(20)];

An Option<T> or Either<L, R> is serializable when its content is serializable by xunit; None always is.

The serializers register themselves. The package's build targets add the three assembly attributes [assembly: Funcky.Xunit.RegisterOptionSerializer], [assembly: Funcky.Xunit.RegisterEitherSerializer] and [assembly: Funcky.Xunit.RegisterUnitSerializer] to every project that has IsTestProject set to true, which Microsoft.NET.Test.Sdk does. For a project that does not, set the MSBuild property FunckyRegisterXunitSerializers to true; set it to false to opt out and add the attributes by hand where you need them.

Where to go next

Analyzer Rules

Funcky ships two sets of Roslyn analyzers that guide you towards correct and idiomatic usage of the library. One written rule decides where a rule lives:

  • Built-in analyzers are part of the Funcky package itself and are always active. A rule is built in when the flagged code is outside the supported surface of the library: the member only exists to support a compiler feature, the value is one the type system permits but Funcky does not support, or the code can never do what it says. Such code is wrong, not merely unidiomatic, so these rules report errors and carry the category Funcky.Correctness.
  • Funcky.Analyzers is an optional package with additional rules. A rule lives there when the flagged code works, but Funcky offers a more idiomatic, shorter or cheaper form. These rules report warnings, always come with a code fix and carry the category Funcky.Style. A rule that is purely a performance hint, such as λ1015, reports an info instead. Install the package with:
    <PackageReference Include="Funcky.Analyzers" Version="..." PrivateAssets="all" />
    

This is the same split the .NET libraries themselves use: usage diagnostics ship inside packages such as System.Text.Json and are always on, while best-practice hints like xunit.analyzers are a package of their own.

Rule numbers

Rule IDs are stable identifiers: once a rule has shipped, its number never changes, because projects configure and suppress rules by ID. The number range therefore records where a rule was born, not where it lives today: λ0xxx rules started as built-in rules, λ1xxx rules started in Funcky.Analyzers, and λ11xx rules concern the FunctionalAssert helpers from the Funcky.Xunit package, and λ12xx rules point at the C# 14 extension forms that 3.8.0 added next to the classic API. The tables below are the authority on which package contains a rule.

Built-in rules

RuleSeverityTitle
λ0001ErrorDisallowed use of TryGetValue
λ0003ErrorMember is not intended for direct usage
λ0004ErrorThe default literal is wrapped by an implicit conversion
λ0005ErrorA null literal is passed to Option, Result or Either
λ1009ErrorDo not use default to instantiate this type (moved from Funcky.Analyzers in 3.7.0)
λ1010ErrorAn option has either zero or one elements (needs Roslyn 4.4, i.e. the .NET 7 SDK or newer)

Funcky.Analyzers

RuleSeverityTitle
λ1001WarningUse of Enumerable.Repeat for a single element
λ1002WarningUse of Enumerable.Repeat with no element
λ1003WarningUse this method with argument names
λ1004WarningUse ConcatToString instead of JoinToString(string.Empty)
λ1005WarningPrefer GetOrElse over Match
λ1006WarningPrefer OrElse over Match
λ1007WarningPrefer SelectMany over Match
λ1008WarningPrefer ToNullable over Match
λ1011WarningPrefer Select over Match
λ1012WarningPrefer the …OrNone method over …OrDefault with a null check
λ1013InfoSequence.Return can be replaced with a collection expression
λ1014WarningThrow expression in an eagerly evaluated fallback
λ1015InfoFallback is computed eagerly
λ1016WarningPass the fallback value directly
λ1017WarningPrefer the named function over the lambda
λ1018WarningPrefer Flatten over SelectMany with the identity function
λ1101WarningAssert can be simplified
λ1201WarningPrefer the static ParseOrNone member on the parsed type
λ1202WarningPrefer the Enumerable form of the Sequence constructor
λ1203WarningPrefer the assertion on Assert over FunctionalAssert

Removed rules

RuleTitleNote
λ0002Use of Option<T>.None() method groupOption<T>.None() was replaced by the Option<T>.None property in 3.0. The rule and its code fix were removed once the method was gone. See the migration guide.

Using the attributes in your own code

Two rules are driven by attributes: λ1003 looks for [UseWithArgumentNames] on methods and λ1009 for [NonDefaultable] on structs. The attributes are internal to Funcky, but the analyzers match them by their full name in the Funcky.CodeAnalysis namespace, so a copy in any assembly counts. Funcky.Analyzers can generate that copy into your project; list the attributes you want in the project file:

<PropertyGroup>
  <FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>
</PropertyGroup>

The generated attributes are internal, so projects that need them generate their own. The generation is opt-in because a project that already has its own copy of an attribute would otherwise get a duplicate type. An unknown name in the list produces the build warning λ.BUILD.0002.

Configuring severity

All rules can be configured in an .editorconfig like any other Roslyn diagnostic, for example:

[*.cs]
dotnet_diagnostic.λ1003.severity = suggestion

The categories let you configure a whole set at once. This turns every style rule into a suggestion while the correctness rules stay errors:

[*.cs]
dotnet_analyzer_diagnostic.category-Funcky.Style.severity = suggestion

The only exception is λ0003, which is not configurable because the flagged members do not work outside their intended syntax.

Opting out of the built-in analyzers

The built-in analyzers are meant to stay on. For build environments where they cannot run, set the MSBuild property FunckyDisableBuiltinAnalyzers to true in the project or in Directory.Build.props; the package's targets then remove the analyzer assemblies from the build. Excluding the analyzer assets on the package reference has the same effect:

<PackageReference Include="Funcky" Version="..." ExcludeAssets="analyzers" />

λ0001: Disallowed use of TryGetValue

TryGetValue should not be used.

Cause

TryGetValue is used outside one of the allowed use cases.

Reason for rule

TryGetValue is an advanced API that is needed to interoperate with imperative constructs, such as loops and catch filter clauses. Use of this API is restricted to prevent misuse. This is critical since TryGetValue is essentially a function to get the value out of the option.

Allowed use cases

TryGetValue may be called anywhere inside

  • the condition of a while, do or for loop,
  • a catch filter clause (catch when (…)),
  • the condition of an if statement in an iterator, if the if statement (or its else branch) contains a yield return or yield break.

The call has to belong to the same method, lambda or local function as the loop, filter or if statement. A call in a lambda that is passed to a loop condition, such as while (items.Any(item => item.Option.TryGetValue(out _))), is not allowed.

Code in Razor components (.razor) and Razor pages and views (.cshtml) is exempt, since Razor's control flow constructs are not visible to the analyzer.

Taking a delegate to TryGetValue (Func<…> f = option.TryGetValue) is never allowed.

How to fix violations

Use one of the functions provided on Option<T> such as Select, SelectMany, Match or GetOrElse.

Examples

Disallowed

static void Example(Option<int> option)
{
    const int fallback = 42;
    var valueOrFallback = option.TryGetValue(out var value) ? value : fallback;
}

Allowed

Loop condition

public static IEnumerable<TItem> Successors<TItem>(Option<TItem> first, Func<TItem, Option<TItem>> successor)
    where TItem : notnull
{
    var item = first;
    while (item.TryGetValue(out var itemValue))
    {
        yield return itemValue;
        item = successor(itemValue);
    }
}

Iterator

public static IEnumerable<TItem> ToEnumerable<TItem>(Option<TItem> option)
    where TItem : notnull
{
    if (option.TryGetValue(out var item))
    {
        yield return item;
    }
}

Catch filter clause

try
{
    // ...
} catch (Exception exception) when (FindHandlerForException(exception).TryGetValue(out var handler) && handler.CanHandle(exception))
{
    handler.Handle(exception);
}

Option<IExceptionHandler> FindHandlerForException(Exception exception) => ...;

λ0003: Member is not intended for direct usage

A member that only exists to support a C# syntax feature is used directly.

Cause

A property marked with [SyntaxSupportOnly] is accessed directly instead of through the syntax it supports. Currently this applies to Option<T>.Count and the indexer Option<T>[int], which exist solely so that list patterns work on Option<T>.

Reason for rule

C# list patterns require a Count (or Length) property and an indexer on the matched type. These members are implementation details of the pattern support and have no meaningful contract on their own: the indexer throws for any index on None, and the Count is never anything other than 0 or 1. Using them directly re-introduces the "check then extract" style of programming that Option<T> is designed to avoid.

This rule is not configurable and is reported as an error, because the members are hidden from IntelliSense and their behavior is not part of the public contract.

How to fix violations

Use a list pattern, or one of the regular Option<T> methods such as Match, Select, SelectMany or GetOrElse.

Examples

Disallowed

static string Example(Option<string> option)
    => option.Count == 1
        ? option[0]
        : "fallback";

Allowed

List pattern

static string Example(Option<string> option)
    => option is [var value]
        ? value
        : "fallback";

Regular API

static string Example(Option<string> option)
    => option.GetOrElse("fallback");

λ0004: The default literal is wrapped by an implicit conversion

A bare default in one branch of an expression that is implicitly converted to Option<T>, Result<T> or Either<L, R> is not the empty monad, it is default(T) wrapped in a Some, an Ok or a Right.

Cause

A default literal is a branch of a conditional (?:), switch or null-coalescing (??) expression, and the whole expression is converted to Option<T>, Result<T> or Either<L, R> through the implicit conversion from the inner type.

Reason for rule

The default literal has no type of its own; it takes the type of the sibling branch. In condition ? 42 : default both branches are int, so the expression is an int and the implicit conversion to Option<int> turns the default branch into Some(0). The code reads as "42 or nothing" but never produces None.

The same happens with Result<T> (an Ok(default) instead of an error) and Either<L, R> (a Right(default) instead of a left). For reference types the compiler reports a nullable warning (CS8604), for value types and tuples it is silent.

A default that is converted directly, without a sibling branch, is fine: Option<int> option = default; is None. A typed default(T) is not reported either, it says what it produces, and neither is a cast such as (int)default outside a branch.

How to fix violations

For an option replace the literal with Option<T>.None, the code fix does this. For a result or an either, construct the error or left value explicitly, e.g. Result<int>.Error(exception) or Either<string, int>.Left("message").

Examples

Disallowed

Option<(string, int)> Lookup(bool condition)
    => condition ? ("hello world", 42) : default; // Some((null, 0)), not None.

Option<int> Parse(int? value) => value ?? default; // Some(0) for null.

Option<string> Name(int id)
    => id switch
    {
        1 => "one",
        _ => default, // Some(null).
    };

Result<int> Compute(bool condition) => condition ? 42 : default; // Ok(0), not an error.

Allowed

Option<(string, int)> Lookup(bool condition)
    => condition ? ("hello world", 42) : Option<(string, int)>.None;

Option<int> Parse(int? value) => Option.FromNullable(value);

Option<string> Name(int id)
    => id switch
    {
        1 => "one",
        _ => Option<string>.None,
    };

Result<int> Compute(bool condition)
    => condition ? 42 : Result<int>.Error(new InvalidOperationException());

// Converted directly, there is no sibling branch: this is None.
Option<int> Empty() => default;

λ0005: A null literal is passed to Option, Result or Either

Option<T>, Result<T> and Either<L, R> never hold null. A null literal that becomes their value throws an ArgumentNullException every time the code runs.

Cause

A null literal is converted to Option<T>, Result<T> or Either<L, R> through the implicit conversion from the inner type, or it is passed to one of the factory methods Option.Some, Option.Return, Result.Ok, Result.Return, Result<T>.Error, Either<L, R>.Left, Either<L, R>.Right or Either<L>.Return.

The literal may sit in a branch of a conditional (?:), switch or null-coalescing (??) expression whose result is converted.

Reason for rule

The implicit conversion Option<string> option = null; does not produce an empty option: it calls Option.Some(null), which throws an ArgumentNullException. The same holds for Result<T> and Either<L, R>.

The comparison option == null is the sneakiest form. It compiles, because null converts to Option<string> through the same implicit conversion, and it throws at run time instead of answering false.

With nullable reference types enabled the compiler reports CS8625 for some of these cases. With nullable reference types disabled it is silent, so this rule is an error in both configurations.

Only the literal is reported. Whether a variable or a method call yields null is a runtime question, and Option.FromNullable exists for exactly that case.

How to fix violations

For an option use Option<T>.None, the code fix does this. A call Option.Some(null) is replaced as a whole.

For a result or an either, construct the error or left value explicitly, e.g. Result<string>.Error(exception) or Either<string, int>.Left("message"). A value that might be null goes through Option.FromNullable(value).

Examples

Disallowed

Option<string> option = null; // Throws ArgumentNullException.

bool IsEmpty(Option<string> option) => option == null; // Throws instead of comparing.

Option<string> Name(bool condition) => condition ? "hello" : null;

Option<string> Empty() => Option.Some<string>(null);

Result<string> Missing() => Result.Ok<string>(null);

Allowed

Option<string> option = Option<string>.None;

bool IsEmpty(Option<string> option) => option == Option<string>.None;

Option<string> Name(bool condition) => condition ? "hello" : Option<string>.None;

// A default option is a valid None.
Option<string> Empty() => default;

// A lifted conversion to a nullable option propagates the null instead of calling the operator.
Option<int>? Missing() => (int?)null;

// Whether a variable is null is decided at run time.
Option<string> Lookup(string value) => Option.FromNullable(value);

λ1001: Use of Enumerable.Repeat for a single element

Enumerable.Repeat(value, 1) should be replaced with Sequence.Return(value).

Cause

Enumerable.Repeat is called with a constant count of 1.

Reason for rule

Repeating a value exactly once is a roundabout way of saying "a sequence containing this one element". Funcky provides Sequence.Return for precisely this purpose. It states the intent directly and mirrors the Return operation of the other monads in the library.

How to fix violations

Replace the call with Sequence.Return(value). A code fix is available.

Examples

Disallowed

IEnumerable<string> greeting = Enumerable.Repeat("hello", 1);

Allowed

IEnumerable<string> greeting = Sequence.Return("hello");

// Repeating a value more than once is fine.
IEnumerable<string> echo = Enumerable.Repeat("hello", 3);

λ1002: Use of Enumerable.Repeat with no element

Enumerable.Repeat(value, 0) should be replaced with Enumerable.Empty<T>().

Cause

Enumerable.Repeat is called with a constant count of 0.

Reason for rule

Repeating a value zero times always yields an empty sequence, and the value is never used. Enumerable.Empty<T>() expresses that directly, does not evaluate an unused argument, and returns a cached singleton instance.

How to fix violations

Replace the call with Enumerable.Empty<T>(), where T is the type of the repeated value. A code fix is available.

Examples

Disallowed

IEnumerable<string> nothing = Enumerable.Repeat("hello", 0);

Allowed

IEnumerable<string> nothing = Enumerable.Empty<string>();

λ1003: Use this method with argument names

Methods such as Match and Switch should be called with named arguments.

Cause

A method annotated with [UseWithArgumentNames] is called and at least one argument is passed positionally. This applies to Match and Switch on Option<T>, Either<L, R>, Result<T> and EitherOrBoth<L, R>.

Calls inside expression trees (for example an Expression<Func<...>> passed to Entity Framework) are exempt, because named arguments are not supported there.

Reason for rule

The arguments of Match and Switch are all functions of a similar shape. Without names it is easy to confuse which branch handles which case, and swapping two branches often compiles without error. Naming the arguments (none:, some:, left:, right:, ok:, error:) documents the intent at the call site and makes the order of the branches irrelevant.

How to fix violations

Add the parameter name to every argument. A code fix is available.

Examples

Disallowed

static string Example(Option<int> option)
    => option.Match(() => "none", value => value.ToString());

Allowed

static string Example(Option<int> option)
    => option.Match(
        none: () => "none",
        some: value => value.ToString());

Using the attribute on your own methods

The attribute is internal to Funcky, but the analyzer matches it by its full name Funcky.CodeAnalysis.UseWithArgumentNamesAttribute, so a copy in your own assembly works just as well. Let Funcky.Analyzers generate that copy by listing the attribute in your project file:

<PropertyGroup>
  <FunckyCodeAnalysisAttributes>UseWithArgumentNames</FunckyCodeAnalysisAttributes>
</PropertyGroup>

Alternatively, add the attribute by hand:

namespace Funcky.CodeAnalysis;

[AttributeUsage(AttributeTargets.Method)]
internal sealed class UseWithArgumentNamesAttribute : Attribute;

Either way, the rule then applies to every method you annotate:

[UseWithArgumentNames]
public static Money Create(decimal amount, string currency) => ...

Money.Create(amount: 10m, currency: "CHF"); // ok
Money.Create(10m, "CHF");                   // λ1003

See Using the attributes in your own code for the details of the generator.

λ1004: Use ConcatToString instead of JoinToString(string.Empty)

Joining a sequence with an empty separator should be expressed as ConcatToString.

Cause

JoinToString is called with "" or string.Empty as the separator.

Reason for rule

JoinToString with an empty separator does not join anything, it concatenates. ConcatToString says exactly that and avoids the reader having to inspect the separator argument.

How to fix violations

Replace .JoinToString(string.Empty) with .ConcatToString(). A code fix is available.

Examples

Disallowed

IEnumerable<string> words = ["fun", "cky"];
string result = words.JoinToString(string.Empty);

Allowed

IEnumerable<string> words = ["fun", "cky"];
string result = words.ConcatToString();

// Any other separator is fine.
string csv = words.JoinToString(", ");

λ1005: Prefer GetOrElse over Match

A Match that returns the inner value or a fallback should be expressed as GetOrElse.

Cause

Match is called on an Option<T>, Either<L, R> or Result<T> where the success branch is the identity function (x => x or Identity) and the error branch produces a value of the same type.

Reason for rule

Match is the most general way to leave a monad; it is the escape hatch that every other operation could be written with. When the success branch just returns the value unchanged, the call re-implements GetOrElse. Using the dedicated method names the intent and is shorter.

How to fix violations

Replace the Match call with GetOrElse, passing the former none/left/error argument. A code fix is available.

Examples

Disallowed

static int Example(Option<int> option)
    => option.Match(none: 42, some: x => x);

static int Example(Option<int> option)
    => option.Match(none: () => 42, some: Identity);

static int Example(Result<int> result)
    => result.Match(ok: x => x, error: _ => 42);

Allowed

static int Example(Option<int> option)
    => option.GetOrElse(42);

static int Example(Option<int> option)
    => option.GetOrElse(() => 42);

static int Example(Result<int> result)
    => result.GetOrElse(_ => 42);

// The success branch transforms the value, so this is a legitimate Match.
static int Example(Option<int> option)
    => option.Match(none: 42, some: x => x + 1);

λ1006: Prefer OrElse over Match

A Match that re-wraps the value on success and returns another monad on failure should be expressed as OrElse.

Cause

Match is called on an Option<T>, Either<L, R> or Result<T>, the result type is the same monad type as the receiver, and the success branch is the monad's Return (Option.Return, Option.Some, Either<L>.Return, Result.Return, or a lambda calling one of them).

Reason for rule

Re-wrapping the value on the success path and substituting a fallback monad on the failure path is exactly what OrElse does. The Match form is longer and hides the fact that the success case is untouched.

How to fix violations

Replace the Match call with OrElse, passing the former none/left/error argument. A code fix is available.

Examples

Disallowed

static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.Match(none: fallback, some: Option.Return);

static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.Match(none: () => fallback, some: x => Option.Some(x));

static Result<int> Example(Result<int> result, Result<int> fallback)
    => result.Match(ok: Result.Return, error: _ => fallback);

Allowed

static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.OrElse(fallback);

static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.OrElse(() => fallback);

static Result<int> Example(Result<int> result, Result<int> fallback)
    => result.OrElse(_ => fallback);

// The success branch changes the value, so this is a legitimate Match.
static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.Match(none: fallback, some: x => Option.Return(x + 1));

λ1007: Prefer SelectMany over Match

A Match that propagates the error state unchanged and maps the success value to a new monad should be expressed as SelectMany.

Cause

Match is called on an Option<T>, Either<L, R> or Result<T>, the result type is the same monad type as the receiver, and the error branch is the monad's error constructor (Option<T>.None, Either<L, R>.Left, Result<T>.Error).

Reason for rule

Passing the error state through and binding a function over the success value is the monadic bind operation, SelectMany. Spelling it out with Match obscures the fact that this is an ordinary chaining step and prevents the use of LINQ query syntax.

How to fix violations

Replace the Match call with SelectMany, passing the former some/right/ok argument. A code fix is available.

Examples

Disallowed

static Option<int> Example(Option<int> option)
    => option.Match(none: Option<int>.None, some: x => Option.Return(x + 10));

static Result<int> Example(Result<int> result)
    => result.Match(error: Result<int>.Error, ok: x => Result.Return(x * 2));

Allowed

static Option<int> Example(Option<int> option)
    => option.SelectMany(x => Option.Return(x + 10));

static Result<int> Example(Result<int> result)
    => result.SelectMany(x => Result.Return(x * 2));

// The result type differs from the receiver, so this is a legitimate Match.
static Option<string> Example(Option<int> option)
    => option.Match(none: Option<string>.None, some: x => x.ToString());

λ1008: Prefer ToNullable over Match

A Match that returns null for None and the value itself for Some should be expressed as ToNullable.

Cause

Match is called on an Option<T> where the none branch is null (or a function returning null) and the some branch is the identity function, resulting in a T?.

Reason for rule

Converting an Option<T> to a nullable value is a common interop step at the boundary to code that does not use Option. ToNullable names this conversion. The Match form requires a cast such as (string?)null to make the type inference work, which is noise that is easy to get wrong.

How to fix violations

Replace the Match call with ToNullable(). A code fix is available.

Examples

Disallowed

static string? Example(Option<string> option)
    => option.Match(none: (string?)null, some: x => x);

static int? Example(Option<int> option)
    => option.Match(none: () => (int?)null, some: Identity);

Allowed

static string? Example(Option<string> option)
    => option.ToNullable();

static int? Example(Option<int> option)
    => option.ToNullable();

// The some branch transforms the value, so this is a legitimate Match.
static string? Example(Option<string> option)
    => option.Match(none: (string?)null, some: x => x + "!");

λ1009: Do not use default to instantiate this type

Types marked [NonDefaultable] must not be created with default or a parameterless constructor.

Cause

A default(T) expression, a default literal, or a parameterless new T() is used with a struct marked with [NonDefaultable]. This applies to Either<L, R>, Result<T> and EitherOrBoth<L, R>.

Reason for rule

These structs have no meaningful "empty" state: an Either is always Left or Right, a Result is always Ok or Error. A default instance is in neither state. Every member on such an instance throws, so a default value is a latent bug that surfaces far away from where it was created.

Option<T> is not affected, because its default is a valid None.

How to fix violations

Construct the value explicitly using one of the constructors, such as Either<L, R>.Left(...), Either<L>.Return(...), Result.Return(...) or Result<T>.Error(...).

Examples

Disallowed

Result<int> result = default;
var either = default(Either<string, int>);
var eitherOrBoth = new EitherOrBoth<string, int>();

Allowed

Result<int> result = Result.Return(42);
var either = Either<string, int>.Left("error");
var eitherOrBoth = EitherOrBoth<string, int>.Both("left", 42);

// Option<T> has a valid default state.
Option<int> none = default;

Using the attribute on your own structs

The attribute is internal to Funcky, but the analyzer matches it by its full name Funcky.CodeAnalysis.NonDefaultableAttribute, so a copy in your own assembly works just as well. The rule itself is built into Funcky; the generator that writes the copy ships with the Funcky.Analyzers package. Install it and list the attribute in your project file:

<PropertyGroup>
  <FunckyCodeAnalysisAttributes>NonDefaultable</FunckyCodeAnalysisAttributes>
</PropertyGroup>

Alternatively, add the attribute by hand:

namespace Funcky.CodeAnalysis;

[AttributeUsage(AttributeTargets.Struct)]
internal sealed class NonDefaultableAttribute : Attribute;

Either way, the rule then applies to every struct you annotate:

[NonDefaultable]
public readonly struct Money { ... }

Money money = default; // λ1009

See Using the attributes in your own code for the details of the generator.

λ1010: An option has either zero or one elements

A list pattern on an Option<T> tests for more elements than an option can contain.

Cause

A list pattern with two or more element patterns is applied to an Option<T>, for example option is [var a, var b].

Reason for rule

Option<T> supports list patterns so that [] matches None and [var value] matches Some. An option never contains more than one element, so any pattern with two or more elements can never match. Such a pattern is almost certainly a mistake and the branch it guards is dead code.

How to fix violations

Use [] to match None and [var value] (or [pattern]) to match Some.

Examples

Disallowed

static string Example(Option<string> option)
    => option is [var first, var second]
        ? first + second
        : "none";

Allowed

static string Example(Option<string> option)
    => option switch
    {
        [var value] => value,
        [] => "none",
    };

static bool IsFoo(Option<string> option)
    => option is ["foo"];

λ1011: Prefer Select over Match

A Match that keeps the failure state and transforms the value should be expressed as Select.

Cause

Match is called on an Option<T>, Either<L, R> or Result<T> where the failure branch returns the monad's own empty or error state (Option<TResult>.None, Either<L, TResult>.Left, Result<TResult>.Error) and the success branch returns a plain value of the inner type.

When the success branch returns a monad itself, the call re-implements SelectMany and λ1007 reports it instead.

Reason for rule

Match is the most general way to leave a monad; it is the escape hatch that every other operation could be written with. When the failure branch merely passes the failure state on and the success branch maps the value, the call re-implements Select. Using the dedicated method names the intent, drops the repeated failure branch and keeps the type parameter inferred.

How to fix violations

Replace the Match call with Select, passing the former some/right/ok argument. A code fix is available.

Examples

Disallowed

static Option<string> Example(Option<int> option)
    => option.Match(none: Option<string>.None, some: x => x.ToString());

static Either<string, int> Example(Either<string, int> either)
    => either.Match(left: Either<string, int>.Left, right: x => x * 2);

static Result<string> Example(Result<int> result)
    => result.Match(error: Result<string>.Error, ok: x => x.ToString());

Allowed

static Option<string> Example(Option<int> option)
    => option.Select(x => x.ToString());

static Either<string, int> Example(Either<string, int> either)
    => either.Select(x => x * 2);

static Result<string> Example(Result<int> result)
    => result.Select(x => x.ToString());

// The failure branch produces a value, so this is a legitimate Match.
static Option<int> Example(Option<int> option, Option<int> fallback)
    => option.Match(none: fallback, some: x => x + 10);

λ1012: Prefer the …OrNone method over …OrDefault with a null check

FirstOrDefault, LastOrDefault, SingleOrDefault and ElementAtOrDefault signal a missing element with null. Funcky's FirstOrNone, LastOrNone, SingleOrNone and ElementAtOrNone return an Option<T> instead.

Cause

The result of one of the LINQ …OrDefault methods is immediately tested for null:

  • xs.FirstOrDefault() ?? fallback
  • xs.FirstOrDefault()?.Member
  • xs.FirstOrDefault() is null, is not null, == null, != null
  • xs.FirstOrDefault() is { } x, is not { } x, is string s

The rule applies to sequences of a reference type. With nullable reference types enabled, sequences of a nullable element type (IEnumerable<string?>) are left alone: there a null result is ambiguous between "no element" and "the element is null". The overloads with an explicit default value (FirstOrDefault(fallback)) and the IQueryable variants are not reported either.

Reason for rule

A null has to be checked by every caller, and nothing forces the check. FirstOrNone returns an Option<T>, so the missing element is a None and the handling is a GetOrElse, Select, Where or Match on the option. The code says what happens when there is no element, and the compiler says when that case is forgotten.

How to fix violations

Replace the …OrDefault call with its …OrNone counterpart and handle the option. For xs.FirstOrDefault() ?? fallback the code fix produces xs.FirstOrNone().GetOrElse(fallback). A ?? evaluates its fallback only when the left side is null, so a fallback that does work (a call, an object creation, an interpolated string, a throw) is passed as a function, GetOrElse(() => CreateFallback()), and keeps that behaviour. There is no fix when the ?? widens the type (values.FirstOrDefault() ?? new object() is an object, which Option<string>.GetOrElse cannot return) or when the fallback cannot move into a lambda (an await, a ref parameter). The other shapes have no single idiomatic replacement, so they are reported without a fix:

BeforeAfter
xs.FirstOrDefault() ?? fallbackxs.FirstOrNone().GetOrElse(fallback)
xs.FirstOrDefault()?.Namexs.FirstOrNone().Select(x => x.Name)
xs.FirstOrDefault() is null!xs.Any() or xs.FirstOrNone().Match(none: true, some: False)
if (xs.FirstOrDefault() is { } x) { A(x); } else { B(); }xs.FirstOrNone().Switch(none: B, some: A)

Examples

Disallowed

static string Example(IEnumerable<string> names)
    => names.FirstOrDefault() ?? "nobody";

static int Example(IEnumerable<string> names)
    => names.SingleOrDefault()?.Length ?? 0;

static void Example(IEnumerable<string> names)
{
    if (names.LastOrDefault() is { } last)
    {
        Console.WriteLine(last);
    }
}

Allowed

static string Example(IEnumerable<string> names)
    => names.FirstOrNone().GetOrElse("nobody");

static int Example(IEnumerable<string> names)
    => names.SingleOrNone().Select(name => name.Length).GetOrElse(0);

static void Example(IEnumerable<string> names)
    => names.LastOrNone().Switch(none: NoOperation, some: Console.WriteLine);

// A null element is a value here, not a missing element.
static string Example(IEnumerable<string?> names)
    => names.FirstOrDefault() ?? "nobody";

λ1013: Sequence.Return can be replaced with a collection expression

A single-element Sequence.Return(x) in a position that supplies a collection target type is the collection expression [x].

Cause

Sequence.Return is called with one element where the compiler knows the target type: an argument, a return, an expression body, a cast, an assignment or an explicitly typed initializer whose type is IEnumerable<T>, IReadOnlyCollection<T> or IReadOnlyList<T>.

The rule only fires when the rewrite changes nothing but the spelling:

  • the file is compiled as C# 12 or later, which collection expressions need;
  • the call is the single-element overload, not the params one. Sequence.Return(array) through the params overload would become [array], a one-element list of arrays;
  • the collection expression has the same element type and binds to the same member, so an overload that only a collection expression would pick is left alone;
  • the call is not inside an expression tree.

var x = Sequence.Return(1) is not reported, because var x = [1] does not compile, and neither is Sequence.Return as the receiver of a LINQ chain: there is no target type to speak of, and Sequence.Return stays the right tool.

Reason for rule

A collection expression is the language's own spelling for a literal collection; it needs no helper and reads like the value it is. The rule is a hint rather than a style violation, so it reports an info.

How to fix violations

Replace the call with the collection expression. The code fix does this and keeps comments inside the call; a range without a left operand is parenthesized so that it does not turn into a spread.

Examples

Disallowed

static IEnumerable<int> Example()
    => Sequence.Return(42);

static void Example()
    => Consume(Sequence.Return("tag"));

static void Consume(IReadOnlyList<string> tags) { }

Allowed

static IEnumerable<int> Example()
    => [42];

static void Example()
    => Consume(["tag"]);

// No target type: the receiver of a LINQ chain.
static IEnumerable<int> Example()
    => Sequence.Return(42).Concat(More());

// The element type differs from the target, so this is not the same collection.
static IEnumerable<object> Example()
    => Sequence.Return("tag");

λ1014: Throw expression in an eagerly evaluated fallback

A throw expression inside the fallback passed to GetOrElse, OrElse or Match throws before the method is even called.

Cause

An argument passed to the eager overload of GetOrElse, OrElse or the none parameter of Match on Option<T>, Result<T> or Either<L, R> contains a throw expression, typically behind ?? or in a branch of ?:.

Reason for rule

Arguments are evaluated before the call. A fallback like fallback ?? throw new Exception() therefore throws whenever fallback is null, no matter whether the option has a value and the fallback would even be needed. The code reads as "throw if there is no value" but throws unconditionally.

Every fallback-taking method has a lazy overload that takes a function instead. The function is only invoked when the fallback is needed, which is where the throw belongs.

How to fix violations

Pass a function instead of the value: prefix the argument with () => for Option<T>, or with _ => for Result<T> and Either<L, R>, whose lazy overloads receive the exception or the left value. A code fix is available.

Examples

Disallowed

static string Example(Option<string> option, string? fallback)
    => option.GetOrElse(fallback ?? throw new InvalidOperationException("no fallback"));

static int Example(Result<int> result, bool condition)
    => result.GetOrElse(condition ? 42 : throw new InvalidOperationException());

Allowed

static string Example(Option<string> option, string? fallback)
    => option.GetOrElse(() => fallback ?? throw new InvalidOperationException("no fallback"));

static int Example(Result<int> result, bool condition)
    => result.GetOrElse(_ => condition ? 42 : throw new InvalidOperationException());

// The lazy overload re-throws the error of the result only when there is no value.
static int Example(Result<int> result)
    => result.GetOrElse(exception => throw exception);

λ1015: Fallback is computed eagerly

The fallback passed to GetOrElse, OrElse or Match is computed even when there is a value and it is never used.

Cause

An argument passed to the eager overload of GetOrElse, OrElse or the none parameter of Match on Option<T>, Result<T> or Either<L, R> does work: it calls a method, creates an object, builds an interpolated string or awaits.

Reason for rule

Arguments are evaluated before the call. option.GetOrElse(LoadDefault()) therefore runs LoadDefault on every call, including the ones where the option has a value and the result is thrown away. Every fallback-taking method has a lazy overload that takes a function instead, which is only invoked when the fallback is needed.

This is a performance hint rather than a correctness issue, so the rule reports an info instead of a warning. The fallback might be cheap, or you might want its side effect regardless; in that case suppress the rule for that line.

Values that cost nothing are never reported: literals, constants, locals, parameters, field and property reads, Option<T>.None, Array.Empty<T>(), Enumerable.Empty<T>() and the constructors of the monads themselves (Option.Some(x), Result.Ok(x), Either<L, R>.Left(x)) as long as their arguments cost nothing either. A throw expression in the fallback is reported by λ1014 instead.

How to fix violations

Pass a function instead of the value: prefix the argument with () => for Option<T>, or with _ => for Result<T> and Either<L, R>, whose lazy overloads receive the exception or the left value. A code fix is available.

Examples

Disallowed

static Settings Example(Option<Settings> settings)
    => settings.GetOrElse(LoadDefaultSettings());

static List<int> Example(Option<List<int>> list)
    => list.GetOrElse(new List<int>());

static string Example(Result<string> result, int id)
    => result.GetOrElse($"missing #{id}");

Allowed

static Settings Example(Option<Settings> settings)
    => settings.GetOrElse(LoadDefaultSettings);

static List<int> Example(Option<List<int>> list)
    => list.GetOrElse(() => new List<int>());

static string Example(Result<string> result, int id)
    => result.GetOrElse(_ => $"missing #{id}");

// Plain values and the empty-collection singletons cost nothing.
static int Example(Option<int> option, int fallback)
    => option.GetOrElse(fallback);

static IEnumerable<int> Example(Option<IEnumerable<int>> sequence)
    => sequence.GetOrElse(Enumerable.Empty<int>());

λ1016: Pass the fallback value directly

A function passed to GetOrElse, OrElse or Match that only returns a plain value should be replaced by the value.

Cause

A lambda is passed to the lazy overload of GetOrElse, OrElse or the none parameter of Match on Option<T>, Result<T> or Either<L, R>, and its body is nothing but a literal, a constant, a local, a parameter of the enclosing method, a field or auto-property read on a receiver that cannot be null, typeof or nameof.

Reason for rule

The lazy overload exists to defer work until the fallback is needed. option.GetOrElse(() => 0f) has no work to defer; the lambda only adds a delegate allocation and noise around the value. The eager overload option.GetOrElse(0f) says the same thing directly.

A lambda that uses its own parameter (exception => …, left => …) or that does work (a method call, an object creation, an indexer, a throw) is a legitimate use of the lazy overload and is not reported. Neither are () => null and () => default, because the bare value would be ambiguous between the two overloads.

How to fix violations

Replace the lambda with its body. A code fix is available.

Examples

Disallowed

static float Example(Option<float> option)
    => option.GetOrElse(() => 0f);

static int Example(Result<int> result, int fallback)
    => result.GetOrElse(_ => fallback);

static Option<int> Example(Option<int> option)
    => option.OrElse(() => Option<int>.None);

Allowed

static float Example(Option<float> option)
    => option.GetOrElse(0f);

static int Example(Result<int> result, int fallback)
    => result.GetOrElse(fallback);

// The function does work that is only needed in the error case.
static Settings Example(Option<Settings> settings)
    => settings.GetOrElse(() => LoadDefaultSettings());

// The function uses the left value.
static int Example(Either<string, int> either)
    => either.GetOrElse(left => left.Length);

λ1017: Prefer the named function over the lambda

A lambda that does what one of the functions in Funcky.Functional does should be replaced by that function.

Cause

A lambda is written where a named function from Funcky.Functional says the same:

LambdaNamed function
x => x, x => { return x; }Identity
() => true, _ => true, (_, _) => true, …True
() => false, _ => false, (_, _) => false, …False
() => { }, _ => { }, (_, _) => { }, …NoOperation

The rule applies to any delegate-typed position: arguments of LINQ and Funcky methods, variable initializers, assignments and return values.

Lambdas that are not exactly one of these are left alone: x => (long)x converts its parameter, _ => Flag returns a constant that may change, async _ => { } returns a task.

The rule only reports when the method group binds the same way as the lambda. items.Where(_ => true) is not reported because Where has an overload with an index parameter and True has overloads with one and two parameters, which makes Where(True) ambiguous. Lambdas inside expression trees are not reported either, because a method group cannot be converted to an expression tree.

Reason for rule

The named function states the intent at a glance: Where(True) reads as "keep everything", Switch(none: NoOperation, …) as "do nothing in that case". The lambda has to be read to arrive at the same conclusion, and a one-character typo turns x => x into something else entirely.

Since C# 11 the compiler caches the delegate created from a static method group, so there is no runtime cost compared to a lambda. Before C# 11 the method group allocates a delegate on each evaluation while a non-capturing lambda is cached once, so on older language versions the rule reports an info instead of a warning.

How to fix violations

Replace the lambda with the named function. A code fix is available; it adds using static Funcky.Functional; when the name is not yet in scope and uses Functional.Identity when the bare name is taken by another member.

Examples

Disallowed

static IEnumerable<int> Example(IEnumerable<Option<int>> options)
    => options.SelectMany(option => option.Select(x => x));

static bool Example(Option<int> option)
    => option.Match(none: false, some: _ => true);

static void Example(Option<string> option)
    => option.Switch(none: () => { }, some: Console.WriteLine);

Allowed

static IEnumerable<int> Example(IEnumerable<Option<int>> options)
    => options.SelectMany(option => option.Select(Identity));

static bool Example(Option<int> option)
    => option.Match(none: false, some: True);

static void Example(Option<string> option)
    => option.Switch(none: NoOperation, some: Console.WriteLine);

// Where(True) would be ambiguous, so the lambda stays.
static IEnumerable<int> Example(IEnumerable<int> items)
    => items.Where(_ => true);

λ1018: Prefer Flatten over SelectMany with the identity function

SelectMany(x => x) collapses one level of nesting. Flatten() does the same and says so by name.

Cause

SelectMany is called with the identity function, x => x or Identity from Funcky.Functional, on a nested Option<Option<T>>, Result<Result<T>>, Either<L, Either<L, R>> or IEnumerable<IEnumerable<T>>. Only the SelectMany of the monads and of System.Linq.Enumerable counts: ParallelEnumerable and Queryable have their own, which Flatten would turn into a plain enumerable.

The overload with a result selector, query syntax (from x in xs from y in x select y) and SelectMany inside an expression tree are not reported.

Reason for rule

A selector that returns its argument is noise: the reader has to look at the lambda to find out that it does nothing. Flatten states the intent and needs no selector. λ1017 would otherwise turn the lambda into SelectMany(Identity), which is still a roundabout way of writing Flatten().

How to fix violations

Replace the SelectMany call with Flatten(). The code fix does this and adds the using Funcky.Monads; or using Funcky.Extensions; when the extension method is not yet in scope. Explicit type arguments select the element type and are kept: SelectMany<IEnumerable<string>, object>(x => x) becomes Flatten<object>(). The fix is only offered when the rewritten call reaches Funcky's Flatten; a Flatten member of the receiver's own type would win over the extension method, so there the call is reported without a fix.

Examples

Disallowed

static Option<int> Example(Option<Option<int>> option)
    => option.SelectMany(x => x);

static IEnumerable<int> Example(IEnumerable<IEnumerable<int>> values)
    => values.SelectMany(Identity);

Allowed

static Option<int> Example(Option<Option<int>> option)
    => option.Flatten();

static IEnumerable<int> Example(IEnumerable<IEnumerable<int>> values)
    => values.Flatten();

// The selector does something.
static Option<int> Example(Option<Option<int>> option)
    => option.SelectMany(inner => inner.Select(x => x + 1));

λ1101: Assert can be simplified

Assert.Equal(expected, FunctionalAssert.Some(option)) should be written as FunctionalAssert.Some(expected, option).

Cause

The result of a FunctionalAssert method that unwraps a monad (Some, Ok, Left, Right) is passed as the actual argument to xUnit's Assert.Equal<T>(T expected, T actual).

This rule ships with Funcky.Analyzers but only applies to code using the Funcky.Xunit package.

Reason for rule

Each of these FunctionalAssert methods has an overload that takes the expected value directly. The single call is shorter and produces a better failure message, because the assertion knows both the expected value and the monad it was extracted from.

How to fix violations

Use the overload that accepts the expected value as its first argument. A code fix is available.

Examples

Disallowed

[Fact]
public void ReturnsTheAnswer()
{
    Option<int> option = Option.Some(42);
    Assert.Equal(42, FunctionalAssert.Some(option));
}

Allowed

[Fact]
public void ReturnsTheAnswer()
{
    Option<int> option = Option.Some(42);
    FunctionalAssert.Some(42, option);
}

// Unwrapping and then asserting something other than equality is fine.
[Fact]
public void ReturnsAPositiveNumber()
{
    Option<int> option = Option.Some(42);
    var value = FunctionalAssert.Some(option);
    Assert.True(value > 0);
}

λ1201: Prefer the static ParseOrNone member on the parsed type

Since 3.8.0 every Parse…OrNone extension on string and on spans also exists as a static extension member on the type it parses. text.ParseInt32OrNone() is int.ParseOrNone(text), which reads like the int.TryParse it wraps.

Cause

A Parse…OrNone extension of ParseExtensions is called on a string, a ReadOnlySpan<char> or a ReadOnlySpan<byte>, the file is compiled as C# 14 or later, and the static member on the parsed type binds for the same arguments. The rule covers the generated members (ParseInt32OrNone, ParseExactDateTimeOrNone, …), the enum form (ParseEnumOrNone<TEnum>) and the generic forms (ParseOrNone<T>, ParseNumberOrNone<T>); the parsed type comes from the Option<T> the extension returns.

A method group (texts.Select(ParseExtensions.ParseInt32OrNone)) is not reported: a static extension member has no method-group form of the same shape.

Reason for rule

The static form is the one that mirrors the base class library, and it is the form that survives if 4.0.0 retires the string family. Adopting it in one sweep is what the code fix is for.

How to fix violations

Call the static member on the parsed type with the text as the first argument. The code fix does this, keeps the other arguments and their names, and supports fix-all.

Examples

Disallowed

Option<int> number = text.ParseInt32OrNone();
Option<DateTime> date = text.ParseExactDateTimeOrNone("O", CultureInfo.InvariantCulture);
Option<Color> color = text.ParseEnumOrNone<Color>(ignoreCase: true);

Allowed

Option<int> number = int.ParseOrNone(text);
Option<DateTime> date = DateTime.ParseExactOrNone(text, "O", CultureInfo.InvariantCulture);
Option<Color> color = Color.ParseOrNone(text, ignoreCase: true);

// A method group has no static counterpart of the same shape.
IEnumerable<Option<int>> numbers = texts.Select(ParseExtensions.ParseInt32OrNone);

λ1202: Prefer the Enumerable form of the Sequence constructor

Since 3.8.0 the constructors of Funcky.Sequence also exist as static extension members of System.Linq.Enumerable, next to Enumerable.Range and Enumerable.Repeat: Sequence.Successors(1, n => n + 1) is Enumerable.Successors(1, n => n + 1).

Cause

A constructor of Sequence (Return, FromNullable, Successors, Cycle, CycleRange, CycleMaterialized, RepeatRange, RepeatMaterialized, Concat) is called, the file is compiled as C# 14 or later, and the same call with Enumerable as the receiver binds to the Enumerable block of EnumerableExtensions. A call that would bind to an overload of Enumerable itself is left alone, and so is one inside an expression tree.

A method group (values.Select(Sequence.Return)) is not reported: a static extension member has no method-group form of the same shape.

Reason for rule

The constructors sit where the base class library keeps its own sequence constructors, so a reader finds them without knowing Funcky's Sequence class. The rule is the upgrade path should a later major version retire Sequence.

How to fix violations

Replace the Sequence receiver with Enumerable. The code fix does this, keeps the arguments and type arguments, and adds using Funcky.Extensions; when the extension members are not in scope yet. It supports fix-all.

Examples

Disallowed

IEnumerable<int> powersOfTwo = Sequence.Successors(1, n => n * 2);
IReadOnlyList<string> tags = Sequence.Return("a", "b");

Allowed

IEnumerable<int> powersOfTwo = Enumerable.Successors(1, n => n * 2);
IReadOnlyList<string> tags = Enumerable.Return("a", "b");

// A method group has no static counterpart of the same shape.
IEnumerable<IReadOnlyList<int>> singletons = values.Select(Sequence.Return);

λ1203: Prefer the assertion on Assert over FunctionalAssert

Since Funcky.Xunit 2.3.0 and Funcky.Xunit.v3 1.1.0 the assertions of FunctionalAssert are also static extension members of xunit's Assert: FunctionalAssert.Some(option) is Assert.Some(option), found where the other assertions live.

Cause

A method of FunctionalAssert is called, the file is compiled as C# 14 or later, and AssertExtensions from a Funcky.Xunit package that has the Assert members is referenced.

Reason for rule

One Assert class for every assertion in a test is easier to discover and to read than two, and the rule is the upgrade path should a later major version retire FunctionalAssert.

How to fix violations

Replace the FunctionalAssert receiver with Assert. The code fix does this, keeps the arguments and type arguments, and adds using Funcky.Extensions; when the extension members are not in scope yet. It supports fix-all.

Examples

Disallowed

FunctionalAssert.Some(42, option);
var value = FunctionalAssert.Ok(result);

Allowed

Assert.Some(42, option);
var value = Assert.Ok(result);

Changelog

All notable changes to this project will be documented in this file. Funcky adheres to Semantic Versioning.

Funcky 3.8.0 | Funcky.Async 1.6.0 | Funcky.Xunit 2.3.0 | Funcky.Xunit.v3 1.1.0 | Funcky.Analyzers 2.1.0

  • Add InRange<TFrom, TTo>(from, to) extension (.NET 7+) on all BCL numeric types to check whether a value lies within an interval. The boundary kinds are given as type arguments Including / Excluding, e.g. 5.InRange<Including, Excluding>(0, 10). Custom types implementing IComparisonOperators<T, T, bool> can use the static NumberExtensions.InRange<TNumber, TFrom, TTo>.
  • The extension methods of Funcky, Funcky.Async and Funcky.Xunit are now C# 14 extension members, declared in extension(...) blocks. The compiled methods keep their names and this parameters, so the change is source and binary compatible. A few methods stay classic extension methods: Option.ToEither and the Compose overloads with a non-unary receiver (an extension member would reorder their type parameters, which breaks callers that name the type arguments), Lazy.Flatten (the compiler does not copy the trimming annotation of a block type parameter onto its marker type, and Flatten returns Lazy<T> of that parameter, which the trimmer rejects with IL2091), and the source-generated …OrNone and Apply methods. Consuming the params extension members (Interleave, And, Or, SplitLazy) with their arguments spread out needs the C# compiler of the .NET SDK 10.0.2xx or newer; the 10.0.1xx compiler reports a bogus CS8620 per argument for params on extension members in referenced assemblies.
  • Every Parse…OrNone extension on string is now also a static extension member on the type it parses, so the function is found where the BCL TryParse lives: int.ParseOrNone(text), DateTime.ParseExactOrNone(text, format, provider, styles), MyEnum.ParseOrNone(text), Enum.ParseOrNone(typeof(MyEnum), text), MediaTypeHeaderValue.ParseOrNone(text), and on .NET 7+ MyParsable.ParseOrNone(text) for every IParsable<T> / ISpanParsable<T> / IUtf8SpanParsable<T> and MyNumber.ParseOrNone(text, style, provider) for every INumberBase<T>. Every overload of the string form is mirrored. The static members live in one class per parsed type (Int32ParseExtensions, DateTimeParseExtensions, EnumParseExtensions, ParsableParseExtensions, NumberParseExtensions, …), because the C# compiler does not allow static extension members of different receiver types with the same name and parameters in one class. Consumers on a compiler older than C# 14 see them as plain static methods of these classes (Int32ParseExtensions.ParseOrNone(text)). The string extensions stay on the 3.x line, but the static form is the recommended one: 4.0.0 decides the fate of the classic forms (#1003), and moving early keeps that migration small. Funcky.Analyzers 2.1.0 ships λ1201 with a code fix for it.
  • The Sequence constructors Return, FromNullable, Successors, Cycle, CycleRange, CycleMaterialized, RepeatRange, RepeatMaterialized and Concat are now also static extension members of System.Linq.Enumerable, next to Enumerable.Range and Enumerable.Repeat: Enumerable.Successors(1, n => n + 1). Enumerable.Concat with exactly two sequences keeps binding to the BCL method, which does the same. The members are declared in EnumerableExtensions, which is what consumers on a compiler older than C# 14 see, and are the implementation; the Sequence constructors forward to them.
  • Add Result.Try(Func<T>) and Result.TryAsync(Func<Task<T>>), which run a function and capture its outcome as Ok(value) or Error(exception): Result.Try(() => int.Parse(text)). Exceptions that signal a corrupted process (OutOfMemoryException, StackOverflowException, AccessViolationException, ThreadAbortException) are never captured, and neither is OperationCanceledException, since a cancelled operation has no result.
  • Add Then on bool as the replacement of Option.FromBoolean, modelled on Rust's bool::then: (number % 2 == 0).Then(number) is Some(number) when the condition holds and None otherwise. Then(() => value) evaluates the value lazily, and Then(() => option) flattens, which replaces the Option.FromBoolean(condition, option).SelectMany(Identity) idiom: request.HasFormContentType.Then(() => request.Form.Files.FirstOrNone()). The members are declared in BooleanExtensions.
  • Deprecated: Option.FromBoolean (all three overloads) is obsolete and will be removed in Funcky 4.0.0; use Then on the condition instead. Funcky.Analyzers 2.1.0 ships a code fix for the obsoletion warning (see below).
  • Funcky.Xunit and Funcky.Xunit.v3: the assertions of FunctionalAssert are now also static extension members of xunit's Assert, so they are found where the other assertions live: Assert.Some(option), Assert.None(option), Assert.Ok(result), Assert.Error(result), Assert.Left(either) and Assert.Right(either); Some, Ok, Left and Right also come with the overload that takes the expected value. The Assert members are the implementation; FunctionalAssert forwards to them and stays the form for compilers older than C# 14 (the members are declared in AssertExtensions).
  • Funcky.Xunit.v3 now accepts xUnit.net v3 1.x through 4.x (xunit.v3.assert and xunit.v3.extensibility.core [1.0.1, 5), previously [1.0.1, 2)). The package is still compiled against 1.0.1; the types it uses (Assert, XunitException, ArgumentFormatter, IXunitSerializer, SerializationHelper) are unchanged in 2.0, 3.0 and 4.0, and the test suite passes against 4.0.2. The Testing with xUnit chapter has a compatibility table with the test host and runner requirements of each xunit version.
  • The asynchronous overloads of AdjacentGroupBy, Chunk, ExclusiveScan, InclusiveScan, Inspect and WhereSelect now follow the .NET 10 naming of System.Linq.AsyncEnumerable: one name per operator, with the asynchronous delegate taking the cancellation token of the enumeration, xs.Inspect(async (item, cancellationToken) => …). This applies to the net10.0 asset of Funcky and to Funcky.Async alike.
  • The same for the terminal aggregations AverageOrNoneAsync, FirstOrNoneAsync, LastOrNoneAsync, MaxOrNoneAsync, MinOrNoneAsync, NoneAsync, PartitionAsync and SingleOrNoneAsync: they gain overloads whose asynchronous delegate receives the cancellation token, xs.FirstOrNoneAsync(async (item, cancellationToken) => …).
  • Deprecated: the …Await and …AwaitWithCancellation overloads of the six lazy operators (InspectAwait, InspectAwaitWithCancellation, …) and the …AwaitAsync and …AwaitWithCancellationAsync overloads of the eight terminal aggregations (FirstOrNoneAwaitAsync, FirstOrNoneAwaitWithCancellationAsync, …) are obsolete and will be removed in Funcky 4.0.0. …AwaitWithCancellation is a rename; …Await additionally gains the token parameter, which can be discarded: (item, _) => …. The obsolete overloads stay a warning for the whole 3.x line. Funcky.Analyzers 2.1.0 ships a code fix with fix-all support (see below).
  • Add OrNone extension properties (C# 14) for base class library members that return null as a regular outcome: Type.BaseTypeOrNone, MemberInfo.DeclaringTypeOrNone, Assembly.EntryPointOrNone, Exception.InnerExceptionOrNone, DirectoryInfo.ParentOrNone and HttpResponseHeaders.LocationOrNone. They are the first extension properties in Funcky; see the Nullable Member Extensions chapter.
  • Announcement: Funcky.DiscriminatedUnion is retired with Funcky 4.0.0, because C# gets native union types and closed hierarchies with .NET 11, which Funcky 4.0.0 adopts. The generator keeps working with the 3.x line and receives no new features. Details in #900.

Analyzers (Funcky.Analyzers 2.1.0)

  • New: a code fix for the obsoletion warning CS0618 on Option.FromBoolean. Option.FromBoolean(condition, value) becomes condition.Then(value), the selector overload keeps its selector, Option.FromBoolean(condition) becomes condition.Then(Unit.Value), and Option.FromBoolean(condition, option).SelectMany(Identity) becomes condition.Then(() => option). The fix adds using Funcky.Extensions; when needed and supports fix-all. A call whose selector returns an Option and that is not flattened gets no fix, because Then would flatten it.
  • The refactoring "Replace with Option.FromBoolean" on Option.Some(value).Where(predicate) now produces predicate.Then(value) and is named "Replace with Then".
  • New: a code fix for the obsoletion warning CS0618 on the …Await and …AwaitWithCancellation operators of AsyncEnumerableExtensions. xs.InspectAwaitWithCancellation((x, ct) => …) becomes xs.Inspect((x, ct) => …), xs.InspectAwait(x => …) becomes xs.Inspect((x, _) => …) with the ignored token parameter added to every delegate argument, and a method group or delegate value becomes a forwarding lambda, xs.Inspect((x, _) => Log(x)). The terminal aggregations map the same way, xs.FirstOrNoneAwaitAsync(x => …) becomes xs.FirstOrNoneAsync((x, _) => …). The fix supports fix-all across a document, project or solution, so a consumer migrates in one step before 4.0.0.
  • New: three opt-in rules with code fixes point at the C# 14 extension forms that 3.8.0 adds next to the classic API, so a solution adopts them in one fix-all. They only fire on C# 14 or newer and skip method groups, which have no static-member form of the same shape:
    • λ1201 text.ParseInt32OrNone() → int.ParseOrNone(text), including the exact, enum and generic forms.
    • λ1202 Sequence.Successors(…) → Enumerable.Successors(…) for every Sequence constructor with an Enumerable form.
    • λ1203 FunctionalAssert.Some(…) → Assert.Some(…).
  • Fix: λ1101 names the receiver as written in its message (Assert.Some); for a static extension member it used to print the unnamed extension block (.Some).
  • Fix: the code fixes that add using Funcky.Extensions; (λ1012, λ1201, λ1202, λ1203 and the Option.FromBoolean fix) share a Fix-All provider that rewrites every flagged call of a document in one pass and adds the directive once. The batch fixer treated three or more identical directive insertions as colliding edits and dropped those fixes, call rewrite included, so a second Fix-All pass was needed (#1009, #1020).

Funcky 3.7.0 | Funcky.Analyzers 2.0.0

The analyzer release: Funcky itself gains built-in correctness rules, Funcky.Analyzers gains eight style rules and a code fix for a compiler warning, and the two packages settle which kind of rule lives where. The analyzer package becomes 2.0.0 because it drops a supported Roslyn version, which is a breaking change under semantic versioning.

Analyzers

The analyzer rules now follow one written rule for which package a rule lives in: a rule is built into the Funcky package when the flagged code is outside the supported surface of the library (the code is wrong, not merely unidiomatic), and it lives in the opt-in Funcky.Analyzers package when the code works but Funcky offers a more idiomatic form. The built-in rules report errors and carry the category Funcky.Correctness, the opt-in rules report warnings (an info for pure performance hints), come with a code fix and carry the category Funcky.Style. See the analyzer rules page.

  • Breaking (Funcky.Analyzers): the package no longer ships an asset for Roslyn 4.0; the analyzers require Roslyn 4.4 (Visual Studio 2022 17.4, .NET SDK 7.0.100) or newer. The Roslyn 4.0 asset was never tested and targeted a Visual Studio version that is out of support.
  • Breaking: λ1009 "Do not use default to instantiate this type" and λ1010 "An option has either zero or one elements" moved from Funcky.Analyzers into the built-in analyzers, so every consumer of Funcky now gets them without installing the analyzer package. The rule IDs stay the same, existing .editorconfig entries keep working. Funcky.Analyzers 2.0.0 no longer contains the two rules; an older Funcky.Analyzers next to Funcky 3.7.0 reports λ1009 twice until it is updated.
  • Breaking: the diagnostic category of every rule changed from Funcky to Funcky.Correctness (built-in) or Funcky.Style (opt-in). A dotnet_analyzer_diagnostic.category-Funcky.severity entry in an .editorconfig needs to name the new categories; per-rule dotnet_diagnostic.λ….severity entries are unaffected. The split lets you tone down all style rules with one line without touching the correctness rules.
  • New: FunckyDisableBuiltinAnalyzers MSBuild property. Setting it to true removes the built-in analyzers from the build, the escape hatch for environments where they cannot run. ExcludeAssets="analyzers" on the package reference has the same effect.
  • Fix: the built-in analyzers are shipped once per supported Roslyn version (roslyn3.7 and roslyn4.4) and the package's buildTransitive targets keep only the roslyn3.7 assets on SDKs without analyzer versioning, the same arrangement System.Text.Json uses. λ1010 needs the Roslyn 4.4 list pattern API and is only active there, which matches the compilers that can use list patterns on Option<T> at all.
  • New: the attributes behind λ1003 ([UseWithArgumentNames]) and λ1009 ([NonDefaultable]) can be used on your own members. The analyzers now match the attributes by name, so a copy in any assembly counts, and Funcky.Analyzers generates that copy when the project sets <FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>.
  • Fixed: λ1003 and λ1009 went silent in a project referencing two assemblies that both define the attribute.
  • Fix: the built-in analyzer λ0001 applied its allowance rules inconsistently. TryGetValue is now allowed anywhere inside a catch filter clause, not only as its outermost expression, so catch when (option.TryGetValue(out var h) && h.CanHandle(e)) compiles. Conversely, a call inside a lambda or local function that is merely used by a loop condition, a catch filter or an if condition in an iterator, such as while (items.Any(i => i.Option.TryGetValue(out _))), is now reported; it used to slip through because the check walked all ancestors. The exemption for .cshtml files finally applies: it recognizes Razor pages and views deriving from RazorPageBase, where it used to require a Blazor ComponentBase. The documentation of λ0001 now lists every allowed use case, including if statements that yield.
  • New: built-in rule λ0004 reports a bare default literal in a branch of a conditional, switch or ?? expression that is implicitly converted to Option<T>, Result<T> or Either<L, R>. The literal takes the type of the sibling branch, so condition ? 42 : default is Some(0) and never None; for value types and tuples the compiler is silent about it. The code fix replaces the literal with Option<T>.None.
  • New: built-in rule λ0005 reports a null literal that is converted to Option<T>, Result<T> or Either<L, R> or passed to one of their factory methods (Option.Some, Result.Ok, Either<L, R>.Left, …). The monads reject null with an ArgumentNullException, so Option<string> option = null; and even option == null throw at run time; with nullable reference types disabled the compiler is silent about it. The code fix replaces the literal with Option<T>.None.
  • New: rule λ1011 reports a Match whose failure branch returns the monad's own empty or error state and whose success branch returns a plain value, e.g. option.Match(none: Option<string>.None, some: x => x.ToString()): that is Select. The code fix rewrites the call for Option<T>, Result<T> and Either<L, R>.
  • New: rule λ1012 reports FirstOrDefault, LastOrDefault, SingleOrDefault and ElementAtOrDefault whose result is immediately tested for null (?? fallback, ?., is null, is {} x, == null, …) and suggests the …OrNone variant, which makes the missing element a None instead of a null. The code fix rewrites xs.FirstOrDefault() ?? fallback to xs.FirstOrNone().GetOrElse(fallback), passes a fallback that does work as a function and adds the using Funcky.Extensions; when needed.
  • New: rule λ1013 reports Sequence.Return(x) in a position with a target type of IEnumerable<T>, IReadOnlyCollection<T> or IReadOnlyList<T> and suggests the collection expression [x], which the code fix produces. It needs C# 12 and only fires where the collection expression binds the same way and keeps the element type; var x = Sequence.Return(1) and the receiver of a LINQ chain are left alone. The rule reports an info.
  • New: rule λ1014 reports a throw expression inside a fallback passed to the eager overload of GetOrElse, OrElse or Match on Option<T>, Result<T> and Either<L, R>, e.g. option.GetOrElse(fallback ?? throw …). The argument is evaluated before the call, so it throws even when there is a value. The code fix passes a function instead.
  • New: rule λ1015 reports a fallback passed to the eager overload of GetOrElse, OrElse or Match on Option<T>, Result<T> and Either<L, R> that does work (a method call, an object creation, an interpolated string, an await), e.g. option.GetOrElse(LoadDefault()): the work happens even when there is a value. The code fix passes a function instead. As a performance hint rather than a correctness issue the rule reports an info, the first rule in the package to do so.
  • New: rule λ1016 reports a lambda passed to the lazy overload of GetOrElse, OrElse or Match on Option<T>, Result<T> and Either<L, R> that only returns a literal, a constant, a variable or a member, e.g. option.GetOrElse(() => 0f): there is no work to defer, so the eager overload taking the value says the same with less. The code fix replaces the lambda with its body.
  • New: rule λ1017 reports a lambda that spells out one of the named functions in Funcky.Functional: x => x is Identity, _ => true is True, _ => false is False and () => { } is NoOperation. It only reports where the method group binds the same way as the lambda, so an ambiguous Where(True) is left alone. The code fix replaces the lambda and adds using static Funcky.Functional; when needed. Before C# 11 the rule reports an info, because the compiler only caches method group delegates from C# 11 on.
  • New: rule λ1018 reports SelectMany(x => x) and SelectMany(Identity) on a nested Option, Result, Either or IEnumerable and suggests Flatten(), which says what happens without a selector to read. The code fix replaces the call.
  • New: a code fix for the compiler warning CS8714 ("Nullability of type argument doesn't match 'notnull' constraint") on Option<T>.Select. A selector that can return null is rewritten to SelectMany with Option.FromNullable, so option.Select(user => user.MiddleName) becomes option.SelectMany(user => Option.FromNullable(user.MiddleName)). The fix handles expression-bodied and block-bodied lambdas and supports fix-all; Either and Result get no fix because they need a failure value.

Funcky 3.6.1 | Funcky.Async 1.5.0 | Funcky.Xunit 2.2.0 | Funcky.Xunit.v3 1.0.0 | Funcky.Analyzers 1.4.2

  • New: AggregateOrNone and AggregateOrNoneAsync, the …OrNone variant of the seedless Aggregate, which throws for an empty sequence. The async variant also takes an accumulator receiving the cancellation token.
  • New: And, Or and Not extension methods on Func<T, bool> in Funcky.Extensions, the extension form of All, Any and Not. The type argument is inferred from the receiver, so isActive.And(isAdmin) needs no <User>.
  • New: Then on Func<…>, the forward form of Compose: f.Then(g) is x => g(f(x)), so a pipeline reads in the order it runs. Overloads mirror Compose, including a nullary function and an Action as the last step.
  • New: FunctionalAssert.Error<TException, TValidResult>(result) asserts that a Result is an error of exactly the given exception type and returns it typed, the analogue of Assert.Throws.
  • Fix: the xUnit v3 Option serializer no longer throws an InvalidOperationException("TODO") when asked whether a null value is serializable; it answers false and reports a failure reason like the interface asks for.
  • Deprecation: the obsolete ImmutableListExtensions (superseded by ListExtensions in 3.0) now announces its removal in Funcky 4.0.0 in the obsolete message.
  • Documentation: SplitLines states that a trailing new line adds no empty line, that an empty text yields nothing and that only CR, LF and CRLF are recognized.
  • New: Unit overrides ToString (prints Unit instead of the type name) and implements the non-generic IComparable.
  • Fix: Flatten (Option, Either, Lazy, IEnumerable), Option.ToEither, Option.ToNullable, Either.SelectLeft, Option.ToAsyncEnumerable and the string Chunk and SlidingWindow are marked [Pure] like their siblings.
  • New: GetNonEnumeratedCountOrNone is available on every target framework; it was limited to .NET 6 and later although the polyfill for the underlying check was already shipped. On older frameworks only collections with a Count are recognized.
  • Fix: Option<T>.Where and Option.FromBoolean(bool, T) no longer allocate a closure per call.
  • Fix: SlidingWindow and SlidingWindowAsync kept the window in an ImmutableQueue and built an ImmutableList for every window. They now use a ring buffer and copy each window into a read-only array, which is O(width) per window with a single allocation instead of a tree per window.
  • Fix: WhereSelect and its async variants no longer allocate an enumerable per element; they are plain iterators now and validate their arguments when called like the other iterator-based extensions.
  • Fix: AnyOrElse with a fallback function invoked the function when AnyOrElse was called if the source was an empty collection, but when the result was enumerated otherwise. The function is now always invoked on enumeration.
  • Fix: every analyzer rule now carries a help link to its documentation page (https://polyadic.github.io/funcky/analyzer-rules/<id>.html); previously only λ0001 had one.
  • Fix: the FunctionalAssert code fix (λ1101) was exported under the name of the argument-name code fix, which broke fix-all grouping in the IDE.
  • Fix: the NonDefaultable and SyntaxSupportOnly marker attributes are removed by the trimmer like UseWithArgumentNames already was; all three are compile-time-only.
  • Packaging: the Funcky.Analyzers package declared its version in a property the SDK does not read, so every package built since February 2025 outside a release was versioned 1.0.0. It uses VersionPrefix like the other packages.
  • Packaging: the Funcky and Funcky.Analyzers packages no longer ship the tools/install.ps1 and uninstall.ps1 hooks, which PackageReference never runs.
  • Fix: AverageOrNone and AverageOrNoneAsync on long (and Option<long>) summed in double and lost precision for large values; they now sum exactly in a checked long like Enumerable.Average, so the result matches the BCL and a sum that overflows long throws OverflowException instead of returning an inexact average. The float overloads sum in double like the BCL. The element count is a long, so sequences with more than two billion elements are averaged correctly. Internally the three per-type aggregator classes became one allocation-free generic struct.
  • Fix: Interleave and Merge (sync and async) acquired the enumerators of all sources before entering the try that disposes them, so if the GetEnumerator of one source threw, the enumerators of the sources before it were never disposed. They are now acquired inside the try and the ones acquired so far are disposed.
  • Fix: Sequence.RepeatRange and AsyncSequence.RepeatRange reject a negative count with an ArgumentOutOfRangeException when they are called, like RepeatMaterialized and Enumerable.Repeat. Previously a negative count silently yielded the source once. The XML docs of all three methods described the result as infinite; it contains the source exactly count times.
  • Documentation: the Funcky.Xunit readme claimed that AggressiveInlining removes the FunctionalAssert frame from the stack trace of a failed assertion on frameworks without [StackTraceHidden]. The JIT never inlines a method with an exception handler, so the attribute was dead; it is removed and the readme now states that on .NET 6 and later the trace starts at the test method, and on older frameworks with a single FunctionalAssert frame above it. Both behaviours are now covered by tests, the latter on .NET Framework 4.8.
  • Build: the public API of every package is now tracked per target framework. The PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt files moved to PublicAPI/<target framework>/ and the public API analyzer runs for every target framework instead of only the newest one. The old single file listed the async APIs for frameworks that do not have them and did not know about APIs that only exist on older frameworks, so a breaking change on those was not detected.
  • Fix: the OrNoneFromTryPattern source generator handles try methods it previously generated broken code for or crashed on: in, ref and ref readonly parameters are forwarded with their modifiers instead of being dropped from the signature, non-null default values (numbers, strings, chars, booleans, enums and default structs) are copied instead of throwing at generation time, overloads that do not follow the try pattern (no or several out parameters, non-bool return, instance methods) are skipped instead of crashing, and two attributed source files with the same file name no longer collide on the generated hint name. Generated methods now carry a summary, so the Parse…OrNone family shows documentation in IntelliSense.
  • New: the generic ParseOrNone<T> (string, ReadOnlySpan<char> and UTF-8 ReadOnlySpan<byte>) and ParseNumberOrNone<T> (string and ReadOnlySpan<char>) gain overloads without the IFormatProvider, which use the current culture like the generated Parse…OrNone methods, and ParseNumberOrNone<T> gains overloads without the NumberStyles, which use the number type's default style.
  • Fix: ParseNumberOrNone<T> on strings accepts a null candidate and returns None, like ParseOrNone<T> and the generated parse methods. All generic parse methods are now marked [Pure] and documented.
  • Fix: Sequence.Return(params T[]) returned the array itself typed as IReadOnlyList<T>, so the result could be cast back to the array and modified. It now returns a read-only view over the array (Array.AsReadOnly), which still does not copy. The XML docs explain that an array passed in is viewed, not copied, and that passing an array binds to the params overload rather than producing a one-element sequence.
  • Breaking: the generic ParseEnumOrNone<TEnum> overloads are constrained to struct, Enum instead of struct, so "1".ParseEnumOrNone<int>() is a compile error instead of an ArgumentException at runtime.
  • Fix: the string overloads of ParseEnumOrNone accept a null candidate and return None, like every other Parse…OrNone method on strings.
  • Fix: on frameworks without ExceptionDispatchInfo.SetCurrentStackTrace (.NET Framework, .NET Standard, .NET Core 3.1), Result<T>.Error captures the stack trace by reflecting on private runtime members in a static initializer. On a runtime that names them differently or cannot compile expression trees, the first Error call failed with a TypeInitializationException. The lookup is now defensive: if it fails, the stack trace is simply not captured and Error works as usual.
  • Fix: OptionEqualityComparer<T>.Default and OptionComparer<T>.Default created a new comparer instance on every read, and every Equals, GetHashCode, == and CompareTo on an Option<T> went through them and through several delegate allocations. Both defaults are now cached per item type like EqualityComparer<T>.Default, and equality, hashing and comparison of options no longer allocate.
  • Fix: GetHashCode and ToString of Either<L, R> and GetHashCode of EitherOrBoth<L, R> threw NotSupportedException for a value constructed with default, so such a value broke HashSet, Dictionary, Distinct, string interpolation and logging. Both members are now total like Equals already was: a defaulted value hashes to 0 and prints as Uninitialized. Match, Switch and everything built on them still throw.
  • New: EitherOrBoth<L, R> overrides ToString (Left(…), Right(…), Both(…, …)).
  • Fix: a Result<T> constructed with default behaved like Ok with a null value, bypassing the null check of Result.Ok. Match, Switch and every operation built on them now throw NotSupportedException for such a value, like Either<L, R> already did. Equals, GetHashCode and ToString stay total. The analyzer λ1009 still reports default(Result<T>) at compile time; see polyadic/funcky#934 for making it a built-in analyzer.
  • Fix: Shuffle and ShuffleAsync without a Random argument used new Random() per call, which on .NET Framework is seeded from the clock, so two shuffles within the same tick returned the same order. They now draw from Random.Shared on .NET 6 and later and from a shared, per-thread generator on older frameworks.
  • Fix: on frameworks before .NET 8, Shuffle built its result by appending to an ImmutableList, which is O(n log n) and allocates per element. It now shuffles an array in place like the .NET 8 implementation, so the result is O(n) and identical for a given seed on every target framework. Both overloads also drop the [Pure] attribute, since they advance the state of the random number generator.
  • Documentation: the XML docs of Shuffle and ShuffleAsync claimed deferred execution; both are eager.
  • Fix: Memoize and CycleRange/RepeatRange for IEnumerable no longer acquire the source's enumerator when the buffer is created, but when the first element is requested, so a source that opens a resource or throws from GetEnumerator does so lazily and a buffer that is disposed unused never touches the source. The source enumerator is disposed as soon as it is exhausted instead of when the buffer is disposed, and Memoize can now be enumerated by several threads concurrently. The IAsyncEnumerable cycle buffer no longer calls MoveNextAsync on an exhausted source, which rewound a .NET 10 compiler-generated iterator and repeated the source instead of the buffer.
  • Fix: the read-only view that Materialize returns for an IList<T> or ICollection<T> source can no longer be used to modify the source through a cast to the mutable interface: IsReadOnly is true and Add, Clear, Remove, Insert, RemoveAt and the indexer setter throw NotSupportedException, like ReadOnlyCollection<T>. The view still reflects later changes to the source; Materialize evaluates a lazy sequence once, it does not take a snapshot.
  • Fix: the iterator-based IAsyncEnumerable extensions (AdjacentGroupBy and its Await variants, AnyOrElse, Chunk, Inspect, InspectEmpty, Interleave, Memoize, Merge, Pairwise, PowerSet, InclusiveScan, ExclusiveScan and their Await variants, SlidingWindow, Split, Transpose, WithFirst, WithLast, WithPrevious, ZipLongest) and AsyncSequence.Successors now validate their arguments when they are called, like LINQ: a null source, selector, comparer or fallback throws an ArgumentNullException naming the parameter immediately, instead of a NullReferenceException when the result is first enumerated.
  • Fix: WithFirst, WithLast and ZipLongest for IAsyncEnumerable now pass the cancellation token given to GetAsyncEnumerator (e.g. via WithCancellation) on to the source sequence.
  • Fix: the iterator-based IEnumerable extensions (AdjacentGroupBy, AnyOrElse, Chunk, Inspect, InspectEmpty, Interleave, Memoize, Merge, Pairwise, PowerSet, InclusiveScan, ExclusiveScan, SlidingWindow, Split, Transpose, WithFirst, WithLast, WithPrevious, ZipLongest) and Sequence.Successors now validate their arguments when they are called, like LINQ: a null source, selector, comparer or fallback throws an ArgumentNullException naming the parameter immediately, instead of a NullReferenceException when the result is first enumerated.
  • Breaking: Split on IEnumerable<T> and IAsyncEnumerable<T> now behaves exactly like string.Split: a separator at the end of the source produces a trailing empty part, and an empty source produces a single empty part. Previously both cases swallowed the empty part, so [1, 0].Split(0) gave [[1]] while [0, 1].Split(0) gave [[], [1]], and the source could not be reconstructed from the parts. The result now always has exactly one part more than there are separators.
  • Fix: MinByOrNone and MaxByOrNone now treat null keys like Enumerable.MinBy/Enumerable.MaxBy: elements whose key is null are never chosen as the minimum or maximum, and if all keys are null the first element is returned. Previously MinByOrNone returned the first element with a null key, because the default comparer orders null before everything else. Both methods also evaluate the key selector only once per element instead of twice.
  • Fix: the retry policies (ConstantDelayPolicy, NoDelayRetryPolicy, LinearBackOffRetryPolicy, ExponentialBackOffRetryPolicy) now reject a negative maxRetries with an ArgumentOutOfRangeException when they are constructed, instead of failing later inside Retry.
  • Fix: ConstantDelayPolicy, LinearBackOffRetryPolicy and ExponentialBackOffRetryPolicy now reject a negative delay with an ArgumentOutOfRangeException when they are constructed, instead of failing later from Thread.Sleep or Task.Delay.
  • Fix: the NuGet packages produced by CI (nightly feed and release artifacts) were built in the Debug configuration. They are now built in Release.
  • Fix: cancellation gaps in the async APIs (Funcky.Async, and Funcky itself on .NET 10):
    • AsyncSequence.Successors now honours the cancellation token passed to GetAsyncEnumerator (e.g. via WithCancellation) and stops before computing the next element.
    • Memoize and CycleRange/RepeatRange for IAsyncEnumerable no longer acquire the source's enumerator when the buffer is created, but when the first element is requested, and they honour each consumer's cancellation token. The memoized buffer can now be enumerated by several consumers concurrently.
    • AnyOrElse for IAsyncEnumerable now awaits with ConfigureAwait(false) like the other operators.
    • The exception-based RetryAsync/RetryAwaitAsync no longer retry once the cancellation token is cancelled, even if shouldRetry matches the exception, and check the token before the first attempt.
  • New: RetryAsync and RetryAwaitAsync overloads whose producer receives the CancellationToken, so an attempt in progress can be aborted.
  • Fix: the Retry and RetryAsync overloads without a retry policy were implemented recursively, so a producer that returned None often enough crashed the process with a StackOverflowException. They are now plain loops.
  • Fix: the Option-based Retry and RetryAsync overloads numbered the retries from 0 while the exception-based overloads numbered them from 1, so the same IRetryPolicy produced different delays depending on the overload. With a LinearBackOffRetryPolicy the Option-based overloads retried immediately the first time. IRetryPolicy.Delay(retryCount) is now documented as one-based (Delay(1) is the wait before the first retry) and all overloads follow that contract.
  • Fix: ExponentialBackOffRetryPolicy now waits firstDelay before the first retry, like LinearBackOffRetryPolicy. Previously the first delay was firstDelay * 1.5 with the exception-based Retry overloads.
  • Fix: Transpose enumerated the outer sequence three times, two of them eagerly when the method was called. It now enumerates the outer sequence exactly once, lazily, when the first column is requested. (#895)
  • Breaking: Transpose now throws an InvalidOperationException when the rows do not all have the same length, instead of silently producing a wrongly shaped result. The behaviour for jagged input was documented as undefined. (#895)
  • Fix: calling Split(separator) on an array in C# 14 (the default for .NET 10) bound to MemoryExtensions.Split(ReadOnlySpan<T>, T) because of first-class span conversions, so the result was a SpanSplitEnumerator<T> instead of an IEnumerable<IReadOnlyList<T>> and the following LINQ call failed to compile. An array overload of Split now keeps arrays bound to Funcky. (#893)
  • Fix: Merge on IEnumerable<T> and IAsyncEnumerable<T> was not stable: when elements from different sources compared equal, the element from the later source was yielded first. Equal elements now keep the order of the sources, like a stable sort. (#891)
  • Fix: WithIndex, WithFirst, WithLast and WithPrevious on an IList<T> source returned a collection whose CopyTo skipped the first arrayIndex elements of the source, so copying it into an array at a non-zero offset (e.g. via List<T>.AddRange or InsertRange on a non-empty list) dropped elements and left default values behind. (#889)
  • Fix: awaiting an empty Option<Task>, Option<Task<T>>, Option<ValueTask> or Option<ValueTask<T>> via the awaiter's OnCompleted method never invoked the continuation. The await keyword was not affected, because the compiler checks IsCompleted first, but custom schedulers and other awaiter-based code could hang forever.
  • The Option awaiters now implement ICriticalNotifyCompletion, so await no longer needs to flow the ExecutionContext when the underlying task awaiter does not either.
  • Add a net10.0 target to Funcky.Async that only forwards all its types to Funcky. Projects that reference both Funcky and Funcky.Async no longer get ambiguous type errors (CS0433) when they move to .NET 10. On .NET 10 you can simply remove the Funcky.Async reference.
  • Fix: Interleave and Merge for IAsyncEnumerable now pass the cancellation token to the inner enumerators (same fix as in Funcky 3.6.0).

Funcky 3.6.0

This update is mainly to update to .NET 10.

  • Funcky now targets net10.0.
  • On .NET 10 and newer, the IAsyncEnumerable extensions, AsyncSequence, AsyncFunctional and the async Option/Result/Either extensions are part of the Funcky package itself, because System.Linq.AsyncEnumerable is now part of the base class library. Funcky does not depend on System.Linq.Async on this framework.
  • Funcky.Async stays the home of these APIs on .NET 9 and older frameworks, where it continues to depend on System.Linq.Async.
  • The Chunk(int) overload for IAsyncEnumerable is not provided on .NET 10; the identically named method in the base class library is used instead and returns arrays rather than IReadOnlyList<T>. The Chunk overloads with a resultSelector are unchanged.
  • AdjacentGroupBy for IAsyncEnumerable returns IGrouping<TKey, TElement> on .NET 10 instead of the IAsyncGrouping<TKey, TElement> type from System.Linq.Async.

Funcky 3.5.1 | Funcky.Async 1.4.1 | Funcky.Xunit 2.1.1 | Funcky.Analyzers 1.4.1

This is a patch release which fixes vulnerability warnings of (direct and transitive) dependencies.

Funcky 3.5.0 | Funcky.Async 1.4.0 | Funcky.Xunit 2.1.0 | Funcky.Analyzers 1.4.0

This update is mainly to update to .NET 9 but also has several smaller improvements.

.NET 9

Thanks to the new [OverloadResolutionPriority] attribute, we've been able finally fix the ambiguous call errors when using DictionaryExtensions.GetValueOrNone:

Dictionary<string, string> favouriteFoods = new();
Option<int> tausFavouriteFood = favouriteFoods.GetValueOrNone("Tau");
//                                             ^^^^^^^^^^^^^^
// This used to produce a `error CS0121: The call is ambiguous between ...` error
// and now simply works 🎉

In good old fashion, we've also added new parse extensions:

  • ParseExtensions.ParseAssemblyNameInfoOrNone
  • ParseExtensions.ParseTypeNameOrNone

Policy for EOL Target Frameworks

We've explicitly written down our policy for how we deal with older / EOL target frameworks.

In short: EOL target frameworks are supported by Funcky until the next major version. However, we may no longer test Funcky against those target frameworks, so we can't guarantee that everything works smoothly.

New APIs

In Funcky 3.0 we've changed CycleRange and RepeatRange to return an IBuffer to enable us to memoize the collection, preventing unnecessary enumeration. This however is unpractical for uses where you want to cycle an already materialized collection. This release adds two new methods to accommodate that use case: CycleMaterialized and RepeatMaterialized.

For convenient downcasting of Option<T>, Result<T> and Either<TLeft, T> values, we've added a new DownCast class:

Option<object> option = Option.Some("hello world");
Option<string> downcasted = DownCast<string>.From(option);

Funcky.XUnit

Funcky.XUnit has been updated to the latest version (2.9.3) of xUnit.net.

Due to changes in xUnit we've had to change the exception type thrown by our FunctionalAssert methods from AssertActualExpectedException to XunitException.

Funcky.Analyzers

The new analyzer rule λ1009 prevents you from accidentally default-instantiating Either, Result and EitherOrBoth.

Funcky.Async

The PowerSet and WithPrevious extensions now correctly pass along the cancellation token.

Funcky 3.4.0 | Funcky.Async 1.3.0 | Funcky.XUnit 2.0.2

This update is mainly to update to .NET 8 but also has several smaller improvements.

Native AOT

Both Funcky and Funcky.Async have been annotated to be compatible with Native AOT. The only exception is OptionJsonSerializer which is not compatible with Native AOT.

.NET 8

We use the new C#12 and .NET features in the code, and expose new features through our API.

  • .NET 8 added new overloads to their TryParse APIs. These changes are reflected in Funcky's ParseOrNone APIs.
    • ParseByteOrNone overloads with ReadOnlySpan<byte> and string?
    • ParseSByteOrNone overloads with ReadOnlySpan<byte>
    • ParseSingleOrNone overloads with ReadOnlySpan<byte>
    • ParseDoubleOrNone overloads with ReadOnlySpan<byte>
    • ParseDecimalOrNone overloads with ReadOnlySpan<byte>
    • ParseInt16OrNone overloads with ReadOnlySpan<byte>
    • ParseInt32OrNone overloads with ReadOnlySpan<byte>
    • ParseInt64OrNone overloads with ReadOnlySpan<byte>
    • ParseUInt16OrNone overloads with ReadOnlySpan<byte>
    • ParseUInt32OrNone overloads with ReadOnlySpan<byte>
    • ParseUInt64OrNone overloads with ReadOnlySpan<byte>
    • ParseNumberOrNone<TNumber> overloads
    • ParseOrNone<TParsable> overloads

String Extensions

We implemented a few of the IEnumerable extensions which are very useful on strings.

  • Chunk on string.
  • SlidingWindow on string.

Monads

  • Implemented UpCast for the monds Option, Either, Result and System.Lazy.
  • Implemented InspectEmpty on IEnumerable and IAsyncEnumerable
  • Implemented ToAsyncEnumerable extension on Option

IEnumerator

  • MoveNextOrNone extension on IEnumerator<T>

Consistency

  • FindIndexOrNone and FindLastIndexOrNone extensions on List

Funcky 3.3.0 | Funcky.Analyzers 1.3.0 | Funcky.Xunit 2.0.1

This is a relatively minor release focuses on convenience for our monads Option, Either and Result.

GetOrElse and OrElse for all

We've added GetOrElse and OrElse to Either and Result bringing them on par with Option.
The corresponding analyzer now also correctly suggests using these methods instead of Match for Result and Either.

Inspect for the error case

All three alternative monads Option, Either and Result now support inspecting the «error» case:

  • Option.InspectNone - executes a side effect only when the option is None.
  • Either.InspectLeft - executes a side effect only when the either is on the Left side.
  • Result.InspectError - executes a side effect only when the result is an Error.

These methods are particularly useful for logging warnings/errors.

Funcky.XUnit

  • Funcky.XUnit is only compatible with XUnit 2.4, this is now correctly declared.

Funcky 3.2.0 | Funcky.Async 1.2.0

List Pattern for Option

We've added support for C# 11's List Patterns to Option<T>. This means that you can use regular switch expressions / statements to match on options:

var greeting = person switch
{
    { FirstName: var firstName, LastName: [var lastName] } => $"Hello {firstName} {lastName}",
    { FirstName: var firstName } => $"Hi {firstName}",
};

record Person(string FirstName, Option<string> LastName);

Discard

The new Discard.__ field provides a short-hand for Unit.Value to be used with switch expressions.

using static Funcky.Discard;

return __ switch
{
    _ when user.IsFrenchAdmin() => "le sécret",
    _ when user.IsAdmin() => "secret",
    _ => "(redacted)",
};

Retry with Exception

We've added overloads to the Retry and RetryAsync functions that allow retrying a function as long as an exception is thrown.

Example from IoRetrier:

// Retries an action until the file is no longer in use.
Task RetryWhileFileIsInUseAsync(IRetryPolicy policy, Action action)
    => RetryAsync(ActionToUnit(action), exception => exception is IOException && exception.HResult == FileInUseHResult, policy);

New Extensions

  • EnumerableExtensions.MinByOrNone
  • EnumerableExtensions.MaxByOrNone
  • ImmutableListExtensions.IndexOfOrNone
  • ImmutableListExtensions.LastIndexOfOrNone
  • ListExtensions.IndexOfOrNone

Funcky 3.1.0 | Funcky.Async 1.1.0 | Funcky.Analyzers 1.2.0

New APIs

  • ✨ OptionExtensions.ToNullable ✨
  • StreamExtensions.ReadByteOrNone
  • New overloads for ElementAtOrNone that take an Index.
  • New overload for JoinToString that takes an IEnumerable<string>.

.NET 7

  • .NET 7 added new overloads to their TryParse APIs. These changes are reflected in Funcky's ParseOrNone APIs.
  • The ParseOrNone methods include the new [StringSyntax] attribute from .NET 7.

Analyzers

The new Option.Match analyzer suggests simpler alternatives over custom Matches including the all-new ToNullable extension.

Funcky 3.0.0 | Funcky.Async 1.0.0 | Funcky.XUnit 2.0.0

There's a handy Migration Guide available.

New APIs

  • Result.GetOrThrow
  • EnumerableExtensions.GetNonEnumeratedCountOrNone

PriorityQueue

  • PriorityQueueExtensions.DequeueOrNone
  • PeekOrNone

Traversable

The new Traverse and Sequence extension methods allow you to «swap» the inner and outer monad (e.g. Result<Option<T>> -> Option<Result<T>>)

Memoize

The new Memoize extension function returns an IBuffer / IAsyncBuffer.
This new type represents ownership over the underlying enumerator (and is therefore IDisposable).

CycleRange and RepeatRange have also been changed to return an IBuffer.

ParseExtensions

The parse extensions have been improved with the goal of aligning more with the BCL. Many of the changes are breaking.

  • The functions now use BCL type names instead of C# type names (e.g. ParseIntOrNone has been renamed to Parse)
  • The parameter names and nullability have been changed to align with the BCL.
  • Added HttpHeadersNonValidatedExtensions

IReadOnlyList / IReadOnlyCollection

Funcky now communicates materialization in the IEnumerable<T> extensions by returning IReadOnlyList or IReadOnlyCollection. This reduces «multiple enumeration» warnings.

  • Materialize
  • Chunk
  • Partition
  • Shuffle
  • SlidingWindow
  • Split
  • Transpose
  • Sequence.Return

Disallowing null Values

Our Option<T> type has always disallowed null values. This has been extended to our other monads: Result<T>, Either<L, R> and Reader<E, R>.

Breaking Changes

  • Option.None() has been changed to a property. There's an automatic fix available for this.
  • Our Match functions now differentiate between Func and Action. The Action overloads have been renamed to Switch.
  • The return type of EnumerableExtensions.ForEach has been changed to Unit.
  • Many parameter names and generic type names have been renamed in an attempt to unify naming across Funcky.
  • All Action extensions have been moved to a new class ActionExtensions.
  • EitherOrBoth has been moved to the Funcky namespace.
  • The retry policies have been moved to the Funcky.RetryPolicies namespace.
  • Partition returns a custom Partitions struct instead of a tuple.

Obsoleted APIs Removed

APIs that have been obsoleted during 2.x have been removed:

  • ObjectExtensions.ToEnumerable
  • Funcky.GenericConstraints.RequireClass and RequireStruct
  • All Try* APIs (TryGetValue, TryParse*, etc.). These APIs use the OrNone suffix instead.
  • Sequence.Generate has been superseded by Sequence.Successors
  • CartesianProduct

JSON Converter

We have removed the implicit System.Text.Json converter for Option<T>. This means that you'll have to register the OptionJsonConverter yourself.
⚠️ Test this change carefully as you won't get an error during compilation, but rather at runtime.

Moved to Funcky.Async

All APIs related to IAsyncEnumerable and Task have been moved to the new Funcky.Async package:

  • AsyncEnumerableExtensions
  • Functional.RetryAsync -> AsyncFunctional.RetryAsync
  • Option<Task> and Option<ValueTask> awaiters

Funcky.Async

AsyncSequence

This class exposes all of the same factory functions as Sequence, but for IAsyncEnumerable:

  • Return
  • Successors
  • Concat
  • Cycle
  • CycleRange
  • FromNullable
  • RepeatRange

New IAsyncEnumerable extensions

We've worked hard towards the goal of parity between our extensions for IEnumerable and IAsyncEnumerable:

  • AdjacentGroupBy
  • AnyOrElse
  • AverageOrNoneAsync / MaxOrNoneAsync / MinOrNoneAsync
  • Chunk
  • ConcatToStringAsync
  • ExclusiveScan
  • InclusiveScan
  • Inspect
  • Interleave
  • Intersperse
  • JoinToStringAsync
  • MaterializeAsync
  • Memoize
  • Merge
  • NoneAsync
  • PartitionAsync
  • PowerSet
  • Sequence / SequenceAsync / Traverse / TraverseAsync
  • ShuffleAsync
  • SlidingWindow
  • Split
  • Transpose
  • WhereNotNull
  • WithIndex / WithLast / WithPrevious / WithFirst
  • ZipLongest

Funcky.Xunit

  • Breaking: The Is prefix has been dropped from assertion methods for consistency with XUnit's naming scheme for assertion methods.

Funcky 2.7.1

Deprecations

  • Option.None<T>(): We originally introduced the Option.None<T> method as a future proof replacement to Option<T>.None for use in method groups, because Funcky 3 changes Option<T>.None to a property. This turned out to be confusing to users especially because both method are always suggested in autocomplete.

Funcky 2.7.0 | Funcky.XUnit 1.0.0 | Funcky.Analyzers 1.1.0

This release is the last non-breaking release for Funcky before 3.0.

Deprecations

  • EnumerableExtensions.CartesianProduct will be removed in Funcky 3.
  • To align our naming with that of the BCL, the ParseOrNone methods that return a type that has a keyword in C# int, long, etc. use the name of the BCL type instead.
    Example: ParseIntOrNone becomes ParseInt32OrNone.
    The old methods will be removed in Funcky 3.
  • In preparation for Funcky 3 we deprecated Option<T>.None when used as method group. Use Option.None<T> instead.

New ParseOrNone extensions

With the help of a source generator we have added a lot of new ParseOrNone methods for various types from the BCL:

  • Unsigned integer types
  • DateOnly, TimeOnly
  • Version
  • Support for ReadOnlySpan<T> as input
  • ... and more

Convenience for Either and Result

  • Added implicit conversions for Either and Result.
  • Implement Inspect for Either and Result.
  • Added Partition for IEnumerable<Either> and IEnumerable<Result>.
  • Added ToString on Either and Result.
  • Implement ToEither on Option.

IEnumerable<T> extensions

  • AnyOrElse
  • Prefix sum: InclusiveScan and ExclusiveScan

Analyzers

This release adds two new analyzer rules:

  • λ1003: Warning when certain methods, such as Match are used without argument labels
  • λ1004: Warning that suggests .ConcatToString() over .JoinToString("")

Both of these warnings come with corresponding code fixes.

Funcky.Xunit

  • Breaking: Funcky.Xunit now uses the Funcky namespace, instead of Funcky.Xunit.
  • Add assertion methods for testing Either: FunctionalAssert.IsLeft and FunctionalAssert.IsRight.

Funcky 2.6.0 | Funcky.Analyzers 1.0.0

Analyzers

This release comes with a new package Funcky.Analyzers, which we'll use to guide users of Funcky

New extensions

  • Add extensions DequeueOrNone and PeekOrNone on Queue and ConcurrentQueue.
  • Add extension ConcatToString as an alias for string.Concat.
  • Add overload to WhereSelect with no parameter.
  • Add methods to convert from Either to Option: #439
    • LeftOrNone: Returns the left value or None if the either value was right.
    • RightOrNone: Returns the right value or None if the either value was left.
  • Extension functions for System.Range to allow the generations of IEnumerable<T>s from Range-Syntax:
    foreach(var i in 1..5) { }
    
    // negative numbers are not supported
    from x in 5..2
    from y in 1..3
    select (x, y)
    

Improvements to Sequence

  • Sequence.Return now accepts multiple parameters:
    Sequence.Return(1, 2, 3)
    
  • ⚠️ Sequence.Generate has been deprecated in favour of the newly added Sequence.Successors function which includes the first element (seed) in the generated sequence.

Improvements to Option

  • Add Option.FromBoolean to create an Option<T> from a boolean.

Improvements to Result

The behaviour of the Result.Error constructor has been changed regarding exceptions with an already set stack trace. The original stack trace is now preserved. Previously this resulted in the stacktrace being replaced (.NET < 5.0) or an error (.NET ≥ 5.0).

Improvements to Either

  • Add Either.Flip to swaps left with right.

Tooling

  • Funcky automatically adds global usings for the most important namespaces of funcky when the FunckyImplicitUsings property is set. This requires .NET SDK ≥ 6.0 and C# ≥ 10.0.
  • Funcky now supports trimming for self-contained deployments.
  • Option<T> now works with the new System.Text.Json source generation.
  • The Funcky package now supports Source Link and deterministic builds.
  • The symbols package is now finally working again.

Funcky 2.5.0

Reader Monad

This release includes the Reader monad including a bunch of factory methods and convenience extensions.

public static Reader<Environment, IEnumerable<string>> DefaultLayout(IEnumerable<DateTime> month)
    => from colorizedMonthName in ColorizedMonthName(month)
       from weekDayLine in WeekDayLine()
       from weeksInMonth in month
        .GroupBy(GetWeekOfYear)
        .Select(FormatWeek)
        .Sequence()
       select BuildDefaultLayout(colorizedMonthName, weekDayLine, weeksInMonth);

Improved Action Extensions

Funcky now supports Curry, Uncurry and Flip for Actions too.
This release also adds the inversion of ActionToUnit: UnitToAction

More Extensions for IEnumerable<T>

  • Intersperse: Adds a given item in between all items of an enumerable.
  • JoinToString: Alias for string.Join.
  • WithPrevious: Similar to WithFirst/Last/Index but with the predecessor of each item.
  • ForEach: Add an overload to ForEach that accepts a Unit-returning Func.

Additional Factory Methods

  • EitherOrBoth.FromOptions creates an EitherOrBoth from two options.
  • Lazy.FromFunc creates a Lazy<T> from a Func.
    This is sugar over the Lazy<T> constructor, with the additional benefit of supporting type inference.
  • Lazy.Return creates a Lazy<T> from a value.
    This is sugar over the Lazy<T> constructor, with the additional benefit of supporting type inference.

Documentation Improvements

This release comes with a few small documentation improvements. Funcky users will now also see the [Pure] attributes which were previously not emitted.

Funcky 2.4.1

  • Remove upper bounds on all Microsoft.Bcl.* dependencies. Between the 2.3.0 and 2.4.0 release an overly restrictive upper bound was accidentally introduced for Microsoft.Bcl.AsyncInterfaces.

Funcky 2.4.0

Try* → *OrNone

We've renamed all Try* methods, such as TryParse, TryGet value to *OrNone. The old methods are still available, but marked as obsolete and will be removed in 3.0.0.

Factory methods for IEnumerable<T>

This release adds some new factory methods for creating IEnumerable<T> to the Sequence class:

  • Sequence.RepeatRange: Generates a sequence that contains the same sequence of elements the given number of times
  • Sequence.Cycle: Cycles the same element over and over again as an endless generator.
  • Sequence.CycleRange: Generates a sequence that contains the same sequence of elements over and over again as an endless generator
  • Sequence.Concat

More Extension Methods

for IEnumerable<T>

  • Materialize: Materializes all the items of a lazy enumerable.
  • PowerSet: Returns a sequence with the set of all subsets
  • Shuffle: Returns the given sequence in random Order in O(n).
  • Split: Splits the source sequence a separator.
  • ZipLongest: Zips two sequences with different lengths.

for string

  • SplitLazy: Splits a string by separator lazily.
  • SplitLines: Splits a string by newline lazily.

for Func

  • Curry
  • Uncurry
  • Flip
  • Compose

EitherOrBoth

EitherOrBoth is a new data type that can represent Left, Right and Both. It is used in ZipLongest.

Monad.Return

This release adds a Return method for all monad types in Funcky:

  • Option.Return
  • Either<TLeft>.Return
  • Result.Return

OptionEqualityComparer

To support more advanced comparison scenarios, OptionEqualityComparer has been added similar to the already existing OptionComparer.

Smaller Improvements

  • Added a missing Match overload to Either that takes Actions
  • Added additional overloads for Functional.True and Functional.False for up to four parameters.

Funcky 2.3.0

  • net5.0 has been added to Funcky's target frameworks.

Improvements to Option<T>

  • Option<T> is now implicitly convertible from T.
    public static Option<int> Answer => 42;
    
  • Option adds support for System.Text.Json:
    The custom JsonConverter is picked up automatically when serializing/deserializing. None is serialized as null and Some(value) is serialized to whatever value serializes to.

Factory methods for IEnumerable<T>

This release adds factory methods for creating IEnumerable<T> with the static class Sequence:

  • Sequence.Return: Creates an IEnumerable<T> with exactly one item.
  • Sequence.FromNullable: Creates an IEnumerable<T> with zero or one items.
  • Sequence.Generate: Creates an IEnumerable<T> using a generation function and a seed.

More Extension Methods for IEnumerable<T>

This release adds a bunch of new extension methods on IEnumerable<T>:

  • AdjacentGroupBy
  • AverageOrNone
  • CartesianProduct
  • Chunk
  • ElementAtOrNone
  • Interleave
  • MaxOrNone
  • Merge
  • MinOrNone
  • Pairwise
  • Partition
  • SlidingWindow
  • TakeEvery
  • Transpose
  • WithFirst
  • WithIndex
  • WithLast

IAsyncEnumerable<T> Support

This release adds a couple of extension methods that provide interoperability with Option<T> to IAsyncEnumerable<T>:

  • WhereSelect
  • FirstOrNoneAsync
  • LastOrNoneAsync
  • SingleOrNoneAsync
  • ElementAtOrNoneAsync

A couple of the new extension methods on IEnumerable<T> have async counterparts:

  • Pairwise
  • TakeEvery

The naming of the extension methods and their overloads follows that of System.Linq.Async.

Improved IQueryable Support

This release adds specialized extension methods for IQueryable<T> that are better suited especially for use with EF Core:

  • FirstOrNone
  • LastOrNone
  • SingleOrNone

Dependencies

To support .NET Standard, Funcky conditionally pulls in dependencies that provide the missing functionality:

  • Microsoft.Bcl.AsyncInterfaces for .NET Standard 2.0
  • System.Collections.Immutable and System.Text.Json for .NET Standard 2.0 and 2.1
  • The version constraints for all these packages have been relaxed to allow 5.x.

Improvements

  • ConfigureAwait(false) is now used everywhere await is used.
  • The IRetryPolicy implementations now use correct Timespan with double multiplication when targeting .NET Standard 2.0.

Deprecations

  • ObjectExtensions.ToEnumerable has been deprecated in favor of Sequence.FromNullable.
  • RequireClass and RequireStruct have been obsoleted with no replacement.

Funcky 2.2.0 | Funcky.xUnit 0.1.3

  • Added overload to Functional.Retry with a IRetryPolicy.
  • Added None overload that takes no predicate.

Funcky 2.1.1 | Funcky.xUnit 0.1.2

  • Re-release of previous release with correct assemblies.

Funcky 2.1.0 | Funcky.xUnit 0.1.1

  • Add Inspect method to Option akin to IEnumerable.Inspect.
  • Add ToTheoryData extension for IEnumerable<T> for xUnit.
  • Add Unit.Value as a way to a get a Unit value.
  • Add Functional.Retry which retries a producer until Option.Some is returned.

Funcky 2.0.0

Breaking Changes

  • Remove Reader monad based on await.
  • Remove IToString.
  • Remove overload for Option.From that flattens passed Options.
  • Move ToEnumerable extension method to its own class. This is only a breaking change if you've used the extension method as normal method. In that case you need to change EnumerableExtensions.ToEnumerable to ObjectExtensions.ToEnumerable.
  • Rename Option.From to Option.FromNullable and remove overload that takes non-nullable value types.
  • Unify Option<T>.ToEnumerable and Yield to ToEnumerable
  • Rename OrElse overloads that return the item to GetOrElse which improves overload resolution.
  • The Each extension method on IEnumerable<T> has been renamed to ForEach.
  • Move the Ok constructor of Result<T> to a non-generic class. This allows for the compiler to infer the generic type. Old: Result<int>.Ok(10). New: Result.Ok(10).
  • Use Func<T, bool> instead of Predicate<T> in predicate composition functions (Functional.All, Functional.Any, Functional.Not), because most APIs in System use Func.
  • Functional.Any now returns false when the given list of predicates is empty.

Fixes

  • Fix incorrect Equals implementation on Option. Equals previously returned true when comparing a None value with a Some value containing the default value of the type.
  • Exception created by Result monad contains valid stack trace
  • Fix incorrect implementation on Result.SelectMany which called the selectedResultSelector even when the result was an error. As a result (pun intended) of the fix, ResultCombinationException is no longer needed and also removed.

Additions

  • Add IndexOfOrNone, LastIndexOfOrNone, IndexOfAnyOrNone and LastIndexOfAnyOrNone extension methods to string.
  • Added Curry, Uncurry and Flip to the Functional Class
  • Add extension method for HttpHeaders.TryGetValues, which returns an Option.
  • Add extension methods for getting Stream properties that are not always available, as Option: GetLengthOrNone, GetPositionOrNone, GetReadTimeoutOrNone, GetWriteTimeoutOrNone.
  • Add None extension method to IEnumerable.
  • Option<Task<T>>, Option<Task> and their ValueTask equivalents are now awaitable:
    var answer = await Option.Some(Task.FromResult(42));
    

Improvements

  • Full nullable support introduced with C# 8.
  • Mark our functions as [Pure].
  • Implement IEquatable on Option, Result and Either.

Funcky 2.0.0-rc.2

  • Move the Ok constructor of Result<T> to a non-generic class. This allows for the compiler to infer the generic type. Old: Result<int>.Ok(10). New: Result.Ok(10).
  • Add IndexOfOrNone, LastIndexOfOrNone, IndexOfAnyOrNone and LastIndexOfAnyOrNone extension methods to string.
  • Rename OrElse overloads that return the item to GetOrElse which improves overload resolution.
  • Added Curry, Uncurry and Flip to the Functional Class
  • Remove IToString.
  • Mark our functions as [Pure].
  • Fix incorrect implementation on Result.SelectMany which called the selectedResultSelector even when the result was an error. As a result (pun intended) of the fix, ResultCombinationException is no longer needed and also removed.

Funcky 2.0.0-rc.1

  • Full nullable support introduced with C# 8
  • Rename Option.From -> Option.FromNullable and remove overload that takes non-nullable value types.
  • Use Func<T, bool> instead of Predicate<T> in predicate composition functions (Functional.All, Functional.Any, Functional.Not), because most APIs in System use Func.
  • Functional.Any now returns false when the given list of predicates is empty.
  • The Each extension method on IEnumerable<T> has been renamed to ForEach.
  • Unify Option<T>.ToEnumerable and Yield to ToEnumerable
  • Remove Reader monad based on await.
  • Exception created by Result monad contains valid stack trace

Funcky 1.8.0

  • Added overload for AndThen which flattens the Option
  • Add Where method to Option<T>, which allows filtering the Option by a predicate.
  • Add overload for Option<T>.SelectMany that takes only a selector.
  • Add WhereNotNull extension method for IEnumerable<T>.

Funcky 1.7.0

  • Add nullability annotations to everything except for Monads.Reader.
  • Add a function for creating an Option<T> from a nullable value: Option.From.
  • Either.Match now throws when called on an Either value created using default(Either<L, R>).
  • Add True and False functions to public API
  • Match of Result Monad accepts actions
  • Add FirstOrNone, LastOrNone and SingleOrNone extension functions

Funcky 1.6.0

  • Add ToEnumerable function to Option<T>.
  • Add WhereSelect extension function for IEnumerable<T>.
  • Add missing overload for nullary actions to ActionToUnit.