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
IEnumerableandstringextensions 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.Analyzerspackage 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
- Update to the latest 2.x version of Funcky (2.7.1) and fix all warnings.
- Update to Funcky 3.0.
- Run
dotnet format analyzers. This will migrateOption<T>.None()toOption<T>.Nonefor you.
This command sometimes fails while loading your project(s).--no-restoremight help with that. - Build and fix other compilation failures.
- You might need to re-run
dotnet format analyzersafter fixing other errors. - Important: Check if you're using
System.Text.Jsonto serializeOptions. This will no longer work automatically. You need to add theOptionJsonConverteryourself.
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:
| Type | Wraps | Select does |
|---|---|---|
IEnumerable<T> | zero or more values | apply to each element |
Option<T> | zero or one value | apply if there is a value |
Result<T> | a value or an exception | apply if there is no error |
Either<L, R> | a left or a right value | apply to the right value |
Reader<E, T> | a value that needs an environment | apply once the environment is given |
Lazy<T> | a value computed on first use | apply, 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 study | Task | Features |
|---|---|---|
Simplify if null by using an Option | Refactor a chain of null checks step by step | Option, FromNullable, Select, AndThen, GetOrElse |
| Formatting a calendar page | Lay out a yearly calendar as a streaming pipeline | Sequence.Successors, AdjacentGroupBy, Chunk, Transpose, JoinToString, Curry |
| Importing a CSV file | Parse and validate records, report or reject bad lines | ParseOrNone, 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
…OrNoneinstead ofTryParsekeeps the parser an expression. ToEitheris the bridge from "no value" to "no value, and here is why".- LINQ query syntax works over
Either,OptionandResult, and short-circuits on the failure case. PartitionandSequenceare 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,PairwiseandInclusiveScanexpress grouping, neighbour comparison and running totals without index arithmetic.- Every
…OrNonein the report makes the empty case explicit, so an empty file produces a report rather than anInvalidOperationExceptionfromMaxorLast.
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 lists the
…OrNonemethods that produce options from BCL calls. - Finding one element covers
FirstOrNoneand friends on sequences, andWhereSelectis how options and sequences combine. - The
if nullcase study walks through migrating an existing method. Result<T>andEither<L, R>follow the same ladder, with an error value in place ofNone.
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
numberwhenTryParsereturnedfalse. Nullable annotations such as[NotNullWhen(true)]patch this for reference types but cannot express it forint. WithOption<int>there is nointto read until you have handled theNonecase. outis a statement, not an expression. You need a variable declared before the call, which means a mutable local, a separateif, and a block. You cannot use aTrymethod in a LINQ query, chain it, pass it as aFunc, 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. ThreeOption<int>compose withSelectManyinto one expression that isNoneif any of them is. - It does not work everywhere. Iterators and
asyncmethods cannot haveoutparameters, 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.
| BCL | Funcky | Available on |
|---|---|---|
byte.TryParse | ParseByteOrNone | all |
sbyte.TryParse | ParseSByteOrNone | all |
short.TryParse | ParseInt16OrNone | all |
ushort.TryParse | ParseUInt16OrNone | all |
int.TryParse | ParseInt32OrNone | all |
uint.TryParse | ParseUInt32OrNone | all |
long.TryParse | ParseInt64OrNone | all |
ulong.TryParse | ParseUInt64OrNone | all |
float.TryParse | ParseSingleOrNone | all |
double.TryParse | ParseDoubleOrNone | all |
decimal.TryParse | ParseDecimalOrNone | all |
BigInteger.TryParse | ParseBigIntegerOrNone | all |
bool.TryParse | ParseBooleanOrNone | all |
char.TryParse | ParseCharOrNone | all |
Enum.TryParse<TEnum> | ParseEnumOrNone<TEnum> | all |
Enum.TryParse(Type, …) | ParseEnumOrNone(Type) | .NET Standard 2.1+ |
Guid.TryParse / Guid.TryParseExact | ParseGuidOrNone / ParseExactGuidOrNone | all |
DateTime.TryParse / TryParseExact | ParseDateTimeOrNone / ParseExactDateTimeOrNone | all |
DateTimeOffset.TryParse / TryParseExact | ParseDateTimeOffsetOrNone / ParseExactDateTimeOffsetOrNone | all |
TimeSpan.TryParse / TryParseExact | ParseTimeSpanOrNone / ParseExactTimeSpanOrNone | all |
DateOnly.TryParse / TryParseExact | ParseDateOnlyOrNone / ParseExactDateOnlyOrNone | .NET 6+ |
TimeOnly.TryParse / TryParseExact | ParseTimeOnlyOrNone / ParseExactTimeOnlyOrNone | .NET 6+ |
Version.TryParse | ParseVersionOrNone | all |
IPAddress.TryParse | ParseIPAddressOrNone | all |
IPEndPoint.TryParse | ParseIPEndPointOrNone | .NET Core 3.0+ |
IPNetwork.TryParse | ParseIPNetworkOrNone | .NET 8+ |
AssemblyNameInfo.TryParse | ParseAssemblyNameInfoOrNone | .NET 9+ |
TypeName.TryParse | ParseTypeNameOrNone | .NET 9+ |
INumberBase<T>.TryParse | ParseNumberOrNone<TNumber> | .NET 7+ |
IParsable<T>.TryParse / ISpanParsable<T>.TryParse | ParseOrNone<TParsable> | .NET 7+ |
IUtf8SpanParsable<T>.TryParse | ParseOrNone<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:
| BCL | Funcky |
|---|---|
AuthenticationHeaderValue.TryParse | ParseAuthenticationHeaderValueOrNone |
CacheControlHeaderValue.TryParse | ParseCacheControlHeaderValueOrNone |
ContentDispositionHeaderValue.TryParse | ParseContentDispositionHeaderValueOrNone |
ContentRangeHeaderValue.TryParse | ParseContentRangeHeaderValueOrNone |
EntityTagHeaderValue.TryParse | ParseEntityTagHeaderValueOrNone |
MediaTypeHeaderValue.TryParse | ParseMediaTypeHeaderValueOrNone |
MediaTypeWithQualityHeaderValue.TryParse | ParseMediaTypeWithQualityHeaderValueOrNone |
NameValueHeaderValue.TryParse | ParseNameValueHeaderValueOrNone |
NameValueWithParametersHeaderValue.TryParse | ParseNameValueWithParametersHeaderValueOrNone |
ProductHeaderValue.TryParse | ParseProductHeaderValueOrNone |
ProductInfoHeaderValue.TryParse | ParseProductInfoHeaderValueOrNone |
RangeConditionHeaderValue.TryParse | ParseRangeConditionHeaderValueOrNone |
RangeHeaderValue.TryParse | ParseRangeHeaderValueOrNone |
RetryConditionHeaderValue.TryParse | ParseRetryConditionHeaderValueOrNone |
StringWithQualityHeaderValue.TryParse | ParseStringWithQualityHeaderValueOrNone |
TransferCodingHeaderValue.TryParse | ParseTransferCodingHeaderValueOrNone |
TransferCodingWithQualityHeaderValue.TryParse | ParseTransferCodingWithQualityHeaderValueOrNone |
ViaHeaderValue.TryParse | ParseViaHeaderValueOrNone |
WarningHeaderValue.TryParse | ParseWarningHeaderValueOrNone |
Collections
| BCL | Funcky | Available on |
|---|---|---|
IDictionary<K, V>.TryGetValue | GetValueOrNone | all |
IReadOnlyDictionary<K, V>.TryGetValue | GetValueOrNone | all |
IDictionary<K, V>.Remove(key, out value) | RemoveOrNone | .NET Standard 2.1+ |
OrderedDictionary<K, V>.IndexOf (returns -1) | IndexOfOrNone | .NET 9+ |
Queue<T>.TryDequeue / TryPeek | DequeueOrNone / PeekOrNone | all ¹ |
ConcurrentQueue<T>.TryDequeue / TryPeek | DequeueOrNone / PeekOrNone | all |
PriorityQueue<T, P>.TryDequeue / TryPeek | DequeueOrNone / PeekOrNone, returning a (Element, Priority) tuple | .NET 6+ |
IEnumerator<T>.MoveNext + Current | MoveNextOrNone | all |
Enumerable.TryGetNonEnumeratedCount | GetNonEnumeratedCountOrNone | .NET 6+ |
IList<T>.IndexOf (returns -1) | IndexOfOrNone | all |
List<T>.FindIndex / FindLastIndex (return -1) | FindIndexOrNone / FindLastIndexOrNone | all |
IImmutableList<T>.IndexOf / LastIndexOf (return -1) | IndexOfOrNone / LastIndexOfOrNone | all |
¹ 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.
| BCL | Funcky |
|---|---|
FirstOrDefault | FirstOrNone |
LastOrDefault | LastOrNone |
SingleOrDefault | SingleOrNone |
ElementAtOrDefault | ElementAtOrNone |
Min / Max (return null for nullable types, throw otherwise) | MinOrNone / MaxOrNone |
MinBy / MaxBy | MinByOrNone / 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.
| BCL | Funcky |
|---|---|
string.IndexOf | IndexOfOrNone |
string.IndexOfAny | IndexOfAnyOrNone |
string.LastIndexOf | LastIndexOfOrNone |
string.LastIndexOfAny | LastIndexOfAnyOrNone |
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.
| BCL | Funcky | Returns None when |
|---|---|---|
Stream.Length | GetLengthOrNone | CanSeek is false |
Stream.Position | GetPositionOrNone | CanSeek is false |
Stream.ReadTimeout | GetReadTimeoutOrNone | CanTimeout is false |
Stream.WriteTimeout | GetWriteTimeoutOrNone | CanTimeout is false |
Stream.ReadByte (returns -1) | ReadByteOrNone | at end of stream |
Miscellaneous
| BCL | Funcky | Available on |
|---|---|---|
HttpHeaders.TryGetValues | GetValuesOrNone | all |
HttpHeadersNonValidated.TryGetValues | GetValuesOrNone | .NET 6+ |
JsonSerializerOptions.TryGetTypeInfo | GetTypeInfoOrNone | .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) |
None | Error(exception) |
Match(none, some) | Match(ok, error) |
Switch(none, some) | Switch(ok, error) |
InspectNone | InspectError |
ToNullable | GetOrThrow |
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) |
InspectError | InspectLeft |
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
Exceptionas 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.
| Helper | Purpose |
|---|---|
| Identity | Returns its argument; the selector that selects nothing |
| NoOperation | Does nothing; the action for callbacks you don't need |
| True, False | Constant predicates |
| All, Any, Not | Combine predicates into a new predicate |
| Curry, Uncurry | Convert between multi-argument and one-argument-at-a-time functions |
| Flip | Swap the first two parameters of a function |
| Compose, Then | Chain two functions into one, backwards or forwards |
| Apply | Fix some arguments of a function, leave the others open |
| Fn | Give a method group a delegate type so it can be passed on |
| Retry | Repeat an operation until it succeeds, with a delay policy |
| Unit | The type with one value, for "returns nothing" in generics |
| ActionToUnit, UnitToAction | Convert 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 to | Use |
|---|---|
| Fix the first argument and pass the rest on | Curry(f)(a) |
| Fix the second argument of a two-argument method | Flip(f).Curry()(b) or f.Apply(__, b) |
| Fix any mix of arguments in one step | f.Apply(…) |
| Build a one-argument-at-a-time function | Curry |
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:
| Policy | Delay 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#:
Voidcan never be used. You must always usevoidvoidcan not be instantiatedvoidcan not be used as a generic argument to any function- A function (sometimes also called method) "returning"
voiddoes 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
nullbecause 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.
| Operation | Description |
|---|---|
| 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 |
| MinByOrNone | The element with the smallest key |
| MaxByOrNone | The element with the largest key |
| AverageOrNone ⁂ | The arithmetic mean |
| AggregateOrNone ⁂ | The seedless fold |
| GetNonEnumeratedCountOrNone | The count, if it is known without enumerating |
Grouping and windowing
Operations that cut one sequence into several. See Grouping and windowing.
| Operation | Description |
|---|---|
| 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.
| Operation | Description |
|---|---|
| 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 |
| Flatten | Concatenate a sequence of sequences |
| CartesianProduct | All 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.
| Operation | Description |
|---|---|
| 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.
| Operation | Description |
|---|---|
| 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.
| Operation | Description |
|---|---|
| ForEach | Run 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.
| Operation | Description |
|---|---|
| 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)
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)
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)
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 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)
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)
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)
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)
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.
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 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)
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 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.
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)
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)
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)
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 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)
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)
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)
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)
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)
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.
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)
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)
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)
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)
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)
The two variants differ in whether an element's own contribution is included in the state yielded for it:
InclusiveScanyields the state after folding in each element. The last value is whatAggregatewould return.ExclusiveScanyields 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)
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
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)
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.
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)
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)
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)
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)
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)
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.
| Constructor | Produces |
|---|---|
| Return | One element, or exactly the elements given |
| FromNullable | One element, or nothing when it is null |
| Successors | Each element computed from the previous one; Aggregate in reverse |
| Cycle | The same element forever |
| CycleRange, CycleMaterialized | A whole sequence, repeated forever |
| RepeatRange, RepeatMaterialized | A whole sequence, repeated n times |
| Concat | Several 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)
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)
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)
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
Funckypackage 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.0up to .NET 9): they are in theFuncky.Asyncpackage, 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.
AdjacentGroupByreturnsIAsyncEnumerable<IAsyncGrouping<TKey, T>>inFuncky.Async, since System.Linq.Async has no synchronous groupings, andIAsyncEnumerable<IGrouping<TKey, T>>on .NET 10. Code that treats a group as a sequence,await grouping.CountAsync()for example, becomesgrouping.Count()after retargeting.Chunk(size)without a result selector is the base class library's on .NET 10 and yieldsT[]instead ofIReadOnlyList<T>. The overloads with a result selector are Funcky's on both sides and keepIReadOnlyList<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.
| Operator | Synchronous delegate | Asynchronous delegate |
|---|---|---|
AdjacentGroupBy | Func<T, TKey> key selector (plus optional element and result selectors) | Func<T, CancellationToken, ValueTask<TKey>>, same for the element and result selectors |
Chunk | Func<IReadOnlyList<T>, TResult> result selector | Func<IReadOnlyList<T>, CancellationToken, ValueTask<TResult>> |
ExclusiveScan | Func<TAccumulate, T, TAccumulate> | Func<TAccumulate, T, CancellationToken, ValueTask<TAccumulate>> |
InclusiveScan | Func<TAccumulate, T, TAccumulate> | Func<TAccumulate, T, CancellationToken, ValueTask<TAccumulate>> |
Inspect | Action<T> | Func<T, CancellationToken, ValueTask> |
WhereSelect | Func<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.
| Operator | Returns | Asynchronous delegate |
|---|---|---|
AverageOrNoneAsync | ValueTask<Option<double>> (or float/decimal) | selector Func<T, CancellationToken, ValueTask<int>> and the other numeric types, also returning Option<…> |
FirstOrNoneAsync | ValueTask<Option<T>> | predicate Func<T, CancellationToken, ValueTask<bool>> |
LastOrNoneAsync | ValueTask<Option<T>> | predicate Func<T, CancellationToken, ValueTask<bool>> |
SingleOrNoneAsync | ValueTask<Option<T>> | predicate Func<T, CancellationToken, ValueTask<bool>> |
MaxOrNoneAsync | ValueTask<Option<TResult>> | selector Func<T, CancellationToken, ValueTask<TResult>> or …ValueTask<Option<TResult>>> |
MinOrNoneAsync | ValueTask<Option<TResult>> | selector Func<T, CancellationToken, ValueTask<TResult>> or …ValueTask<Option<TResult>>> |
NoneAsync | ValueTask<bool> | predicate Func<T, CancellationToken, ValueTask<bool>> |
PartitionAsync | ValueTask<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:
| Obsolete | Replacement | Change 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
…AwaitWithCancellationand…AwaitWithCancellationAsynccalls and leaves the delegate alone; - renames
…Awaitand…AwaitAsynccalls 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 aFunc<T, CancellationToken, ValueTask>is expected:xs.InspectAwait(Log)becomesxs.Inspect((x, _) => Log(x)), andxs.InspectAwait(this.inspector)becomesxs.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>.
| Constructor | Description |
|---|---|
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 method | Funcky | None when |
|---|---|---|
Length | GetLengthOrNone() | CanSeek is false |
Position | GetPositionOrNone() | CanSeek is false |
ReadTimeout | GetReadTimeoutOrNone() | CanTimeout is false |
WriteTimeout | GetWriteTimeoutOrNone() | CanTimeout is false |
ReadByte(), which returns -1 | ReadByteOrNone() | 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
| Property | On | Wraps |
|---|---|---|
BaseTypeOrNone | Type | Type.BaseType |
DeclaringTypeOrNone | MemberInfo (and therefore Type) | MemberInfo.DeclaringType |
EntryPointOrNone | Assembly | Assembly.EntryPoint |
InnerExceptionOrNone | Exception | Exception.InnerException |
ParentOrNone | DirectoryInfo | DirectoryInfo.Parent |
LocationOrNone | HttpResponseHeaders | HttpResponseHeaders.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 version | Package |
|---|---|
| 2.x | Funcky.Xunit |
| v3, 1.x through 4.x | Funcky.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.v3 | Funcky.Xunit.v3 | Test project runs on | dotnet test with the .NET 10 SDK |
|---|---|---|---|
| 1.x | 1.0.0 and later | .NET 6+, .NET Framework 4.7.2+ | VSTest (xunit.runner.visualstudio) or Microsoft.Testing.Platform v1 |
| 2.x | 1.1.0 and later, expected to work | .NET 8+, .NET Framework 4.7.2+ | VSTest or Microsoft.Testing.Platform v1 |
| 3.x | 1.1.0 and later, expected to work | .NET 8+, .NET Framework 4.7.2+ | VSTest or Microsoft.Testing.Platform v1 |
| 4.x | 1.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.
| Monad | Case | Expected-value overload | Returning overload |
|---|---|---|---|
Option<T> | Some | Assert.Some(expected, option) | T Assert.Some(option) |
Option<T> | None | Assert.None(option) | |
Result<T> | Ok | Assert.Ok(expected, result) | T Assert.Ok(result) |
Result<T> | Error | Exception Assert.Error(result) | |
Result<T> | Error | TException Assert.Error<TException, T>(result) | |
Either<L, R> | Left | Assert.Left(expected, either) | L Assert.Left(either) |
Either<L, R> | Right | Assert.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
- Option Monad, Result Monad and Either Monad describe the types the assertions unwrap.
- λ1101 and λ1203 are the two analyzer rules for test code.
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
Funckypackage 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 categoryFuncky.Correctness. Funcky.Analyzersis 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 categoryFuncky.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
| Rule | Severity | Title |
|---|---|---|
| λ0001 | Error | Disallowed use of TryGetValue |
| λ0003 | Error | Member is not intended for direct usage |
| λ0004 | Error | The default literal is wrapped by an implicit conversion |
| λ0005 | Error | A null literal is passed to Option, Result or Either |
| λ1009 | Error | Do not use default to instantiate this type (moved from Funcky.Analyzers in 3.7.0) |
| λ1010 | Error | An option has either zero or one elements (needs Roslyn 4.4, i.e. the .NET 7 SDK or newer) |
Funcky.Analyzers
| Rule | Severity | Title |
|---|---|---|
| λ1001 | Warning | Use of Enumerable.Repeat for a single element |
| λ1002 | Warning | Use of Enumerable.Repeat with no element |
| λ1003 | Warning | Use this method with argument names |
| λ1004 | Warning | Use ConcatToString instead of JoinToString(string.Empty) |
| λ1005 | Warning | Prefer GetOrElse over Match |
| λ1006 | Warning | Prefer OrElse over Match |
| λ1007 | Warning | Prefer SelectMany over Match |
| λ1008 | Warning | Prefer ToNullable over Match |
| λ1011 | Warning | Prefer Select over Match |
| λ1012 | Warning | Prefer the …OrNone method over …OrDefault with a null check |
| λ1013 | Info | Sequence.Return can be replaced with a collection expression |
| λ1014 | Warning | Throw expression in an eagerly evaluated fallback |
| λ1015 | Info | Fallback is computed eagerly |
| λ1016 | Warning | Pass the fallback value directly |
| λ1017 | Warning | Prefer the named function over the lambda |
| λ1018 | Warning | Prefer Flatten over SelectMany with the identity function |
| λ1101 | Warning | Assert can be simplified |
| λ1201 | Warning | Prefer the static ParseOrNone member on the parsed type |
| λ1202 | Warning | Prefer the Enumerable form of the Sequence constructor |
| λ1203 | Warning | Prefer the assertion on Assert over FunctionalAssert |
Removed rules
| Rule | Title | Note |
|---|---|---|
| λ0002 | Use of Option<T>.None() method group | Option<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,doorforloop, - a
catchfilter clause (catch when (…)), - the condition of an
ifstatement in an iterator, if theifstatement (or itselsebranch) contains ayield returnoryield 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() ?? fallbackxs.FirstOrDefault()?.Memberxs.FirstOrDefault() is null,is not null,== null,!= nullxs.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:
| Before | After |
|---|---|
xs.FirstOrDefault() ?? fallback | xs.FirstOrNone().GetOrElse(fallback) |
xs.FirstOrDefault()?.Name | xs.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
paramsone.Sequence.Return(array)through theparamsoverload 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:
| Lambda | Named 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 argumentsIncluding/Excluding, e.g.5.InRange<Including, Excluding>(0, 10). Custom types implementingIComparisonOperators<T, T, bool>can use the staticNumberExtensions.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 andthisparameters, so the change is source and binary compatible. A few methods stay classic extension methods:Option.ToEitherand theComposeoverloads 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, andFlattenreturnsLazy<T>of that parameter, which the trimmer rejects with IL2091), and the source-generated…OrNoneandApplymethods. Consuming theparamsextension 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 forparamson extension members in referenced assemblies. - Every
Parse…OrNoneextension onstringis now also a static extension member on the type it parses, so the function is found where the BCLTryParselives: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 everyIParsable<T>/ISpanParsable<T>/IUtf8SpanParsable<T>andMyNumber.ParseOrNone(text, style, provider)for everyINumberBase<T>. Every overload of thestringform 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)). Thestringextensions 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
SequenceconstructorsReturn,FromNullable,Successors,Cycle,CycleRange,CycleMaterialized,RepeatRange,RepeatMaterializedandConcatare now also static extension members ofSystem.Linq.Enumerable, next toEnumerable.RangeandEnumerable.Repeat:Enumerable.Successors(1, n => n + 1).Enumerable.Concatwith exactly two sequences keeps binding to the BCL method, which does the same. The members are declared inEnumerableExtensions, which is what consumers on a compiler older than C# 14 see, and are the implementation; theSequenceconstructors forward to them. - Add
Result.Try(Func<T>)andResult.TryAsync(Func<Task<T>>), which run a function and capture its outcome asOk(value)orError(exception):Result.Try(() => int.Parse(text)). Exceptions that signal a corrupted process (OutOfMemoryException,StackOverflowException,AccessViolationException,ThreadAbortException) are never captured, and neither isOperationCanceledException, since a cancelled operation has no result. - Add
Thenonboolas the replacement ofOption.FromBoolean, modelled on Rust'sbool::then:(number % 2 == 0).Then(number)isSome(number)when the condition holds andNoneotherwise.Then(() => value)evaluates the value lazily, andThen(() => option)flattens, which replaces theOption.FromBoolean(condition, option).SelectMany(Identity)idiom:request.HasFormContentType.Then(() => request.Form.Files.FirstOrNone()). The members are declared inBooleanExtensions. - Deprecated:
Option.FromBoolean(all three overloads) is obsolete and will be removed in Funcky 4.0.0; useThenon 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
FunctionalAssertare now also static extension members of xunit'sAssert, so they are found where the other assertions live:Assert.Some(option),Assert.None(option),Assert.Ok(result),Assert.Error(result),Assert.Left(either)andAssert.Right(either);Some,Ok,LeftandRightalso come with the overload that takes the expected value. TheAssertmembers are the implementation;FunctionalAssertforwards to them and stays the form for compilers older than C# 14 (the members are declared inAssertExtensions). - Funcky.Xunit.v3 now accepts xUnit.net v3 1.x through 4.x (
xunit.v3.assertandxunit.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,InspectandWhereSelectnow follow the .NET 10 naming ofSystem.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,PartitionAsyncandSingleOrNoneAsync: they gain overloads whose asynchronous delegate receives the cancellation token,xs.FirstOrNoneAsync(async (item, cancellationToken) => …). - Deprecated: the
…Awaitand…AwaitWithCancellationoverloads of the six lazy operators (InspectAwait,InspectAwaitWithCancellation, …) and the…AwaitAsyncand…AwaitWithCancellationAsyncoverloads of the eight terminal aggregations (FirstOrNoneAwaitAsync,FirstOrNoneAwaitWithCancellationAsync, …) are obsolete and will be removed in Funcky 4.0.0.…AwaitWithCancellationis a rename;…Awaitadditionally 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
OrNoneextension properties (C# 14) for base class library members that returnnullas a regular outcome:Type.BaseTypeOrNone,MemberInfo.DeclaringTypeOrNone,Assembly.EntryPointOrNone,Exception.InnerExceptionOrNone,DirectoryInfo.ParentOrNoneandHttpResponseHeaders.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
uniontypes andclosedhierarchies 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)becomescondition.Then(value), the selector overload keeps its selector,Option.FromBoolean(condition)becomescondition.Then(Unit.Value), andOption.FromBoolean(condition, option).SelectMany(Identity)becomescondition.Then(() => option). The fix addsusing Funcky.Extensions;when needed and supports fix-all. A call whose selector returns anOptionand that is not flattened gets no fix, becauseThenwould flatten it. - The refactoring "Replace with Option.FromBoolean" on
Option.Some(value).Where(predicate)now producespredicate.Then(value)and is named "Replace with Then". - New: a code fix for the obsoletion warning CS0618 on the
…Awaitand…AwaitWithCancellationoperators ofAsyncEnumerableExtensions.xs.InspectAwaitWithCancellation((x, ct) => …)becomesxs.Inspect((x, ct) => …),xs.InspectAwait(x => …)becomesxs.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 => …)becomesxs.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:
- 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 theOption.FromBooleanfix) 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 usedefaultto instantiate this type" andλ1010"An option has either zero or one elements" moved fromFuncky.Analyzersinto the built-in analyzers, so every consumer ofFunckynow gets them without installing the analyzer package. The rule IDs stay the same, existing.editorconfigentries keep working. Funcky.Analyzers 2.0.0 no longer contains the two rules; an older Funcky.Analyzers next to Funcky 3.7.0 reportsλ1009twice until it is updated. - Breaking: the diagnostic category of every rule changed from
FunckytoFuncky.Correctness(built-in) orFuncky.Style(opt-in). Adotnet_analyzer_diagnostic.category-Funcky.severityentry in an.editorconfigneeds to name the new categories; per-ruledotnet_diagnostic.λ….severityentries are unaffected. The split lets you tone down all style rules with one line without touching the correctness rules. - New:
FunckyDisableBuiltinAnalyzersMSBuild property. Setting it totrueremoves 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.7androslyn4.4) and the package'sbuildTransitivetargets keep only theroslyn3.7assets on SDKs without analyzer versioning, the same arrangement System.Text.Json uses.λ1010needs the Roslyn 4.4 list pattern API and is only active there, which matches the compilers that can use list patterns onOption<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, andFuncky.Analyzersgenerates that copy when the project sets<FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>. - Fixed:
λ1003andλ1009went silent in a project referencing two assemblies that both define the attribute. - Fix: the built-in analyzer λ0001 applied its allowance rules inconsistently.
TryGetValueis now allowed anywhere inside acatchfilter clause, not only as its outermost expression, socatch 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 anifcondition in an iterator, such aswhile (items.Any(i => i.Option.TryGetValue(out _))), is now reported; it used to slip through because the check walked all ancestors. The exemption for.cshtmlfiles finally applies: it recognizes Razor pages and views deriving fromRazorPageBase, where it used to require a BlazorComponentBase. The documentation of λ0001 now lists every allowed use case, includingifstatements that yield. - New: built-in rule
λ0004reports a baredefaultliteral in a branch of a conditional,switchor??expression that is implicitly converted toOption<T>,Result<T>orEither<L, R>. The literal takes the type of the sibling branch, socondition ? 42 : defaultisSome(0)and neverNone; for value types and tuples the compiler is silent about it. The code fix replaces the literal withOption<T>.None. - New: built-in rule
λ0005reports anullliteral that is converted toOption<T>,Result<T>orEither<L, R>or passed to one of their factory methods (Option.Some,Result.Ok,Either<L, R>.Left, …). The monads rejectnullwith anArgumentNullException, soOption<string> option = null;and evenoption == nullthrow at run time; with nullable reference types disabled the compiler is silent about it. The code fix replaces the literal withOption<T>.None. - New: rule
λ1011reports aMatchwhose 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 isSelect. The code fix rewrites the call forOption<T>,Result<T>andEither<L, R>. - New: rule
λ1012reportsFirstOrDefault,LastOrDefault,SingleOrDefaultandElementAtOrDefaultwhose result is immediately tested fornull(?? fallback,?.,is null,is {} x,== null, …) and suggests the…OrNonevariant, which makes the missing element aNoneinstead of anull. The code fix rewritesxs.FirstOrDefault() ?? fallbacktoxs.FirstOrNone().GetOrElse(fallback), passes a fallback that does work as a function and adds theusing Funcky.Extensions;when needed. - New: rule
λ1013reportsSequence.Return(x)in a position with a target type ofIEnumerable<T>,IReadOnlyCollection<T>orIReadOnlyList<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
λ1014reports athrowexpression inside a fallback passed to the eager overload ofGetOrElse,OrElseorMatchonOption<T>,Result<T>andEither<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
λ1015reports a fallback passed to the eager overload ofGetOrElse,OrElseorMatchonOption<T>,Result<T>andEither<L, R>that does work (a method call, an object creation, an interpolated string, anawait), 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
λ1016reports a lambda passed to the lazy overload ofGetOrElse,OrElseorMatchonOption<T>,Result<T>andEither<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
λ1017reports a lambda that spells out one of the named functions inFuncky.Functional:x => xisIdentity,_ => trueisTrue,_ => falseisFalseand() => { }isNoOperation. It only reports where the method group binds the same way as the lambda, so an ambiguousWhere(True)is left alone. The code fix replaces the lambda and addsusing 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
λ1018reportsSelectMany(x => x)andSelectMany(Identity)on a nestedOption,Result,EitherorIEnumerableand suggestsFlatten(), 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 returnnullis rewritten toSelectManywithOption.FromNullable, sooption.Select(user => user.MiddleName)becomesoption.SelectMany(user => Option.FromNullable(user.MiddleName)). The fix handles expression-bodied and block-bodied lambdas and supports fix-all;EitherandResultget 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:
AggregateOrNoneandAggregateOrNoneAsync, the…OrNonevariant of the seedlessAggregate, which throws for an empty sequence. The async variant also takes an accumulator receiving the cancellation token. - New:
And,OrandNotextension methods onFunc<T, bool>inFuncky.Extensions, the extension form ofAll,AnyandNot. The type argument is inferred from the receiver, soisActive.And(isAdmin)needs no<User>. - New:
ThenonFunc<…>, the forward form ofCompose:f.Then(g)isx => g(f(x)), so a pipeline reads in the order it runs. Overloads mirrorCompose, including a nullary function and anActionas the last step. - New:
FunctionalAssert.Error<TException, TValidResult>(result)asserts that aResultis an error of exactly the given exception type and returns it typed, the analogue ofAssert.Throws. - Fix: the xUnit v3
Optionserializer no longer throws anInvalidOperationException("TODO")when asked whether anullvalue is serializable; it answersfalseand reports a failure reason like the interface asks for. - Deprecation: the obsolete
ImmutableListExtensions(superseded byListExtensionsin 3.0) now announces its removal in Funcky 4.0.0 in the obsolete message. - Documentation:
SplitLinesstates 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:
UnitoverridesToString(printsUnitinstead of the type name) and implements the non-genericIComparable. - Fix:
Flatten(Option, Either, Lazy, IEnumerable),Option.ToEither,Option.ToNullable,Either.SelectLeft,Option.ToAsyncEnumerableand the stringChunkandSlidingWindoware marked[Pure]like their siblings. - New:
GetNonEnumeratedCountOrNoneis 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 aCountare recognized. - Fix:
Option<T>.WhereandOption.FromBoolean(bool, T)no longer allocate a closure per call. - Fix:
SlidingWindowandSlidingWindowAsynckept the window in anImmutableQueueand built anImmutableListfor 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:
WhereSelectand 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:
AnyOrElsewith a fallback function invoked the function whenAnyOrElsewas 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
FunctionalAssertcode fix (λ1101) was exported under the name of the argument-name code fix, which broke fix-all grouping in the IDE. - Fix: the
NonDefaultableandSyntaxSupportOnlymarker attributes are removed by the trimmer likeUseWithArgumentNamesalready 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
VersionPrefixlike the other packages. - Packaging: the Funcky and Funcky.Analyzers packages no longer ship the
tools/install.ps1anduninstall.ps1hooks, whichPackageReferencenever runs. - Fix:
AverageOrNoneandAverageOrNoneAsynconlong(andOption<long>) summed indoubleand lost precision for large values; they now sum exactly in a checkedlonglikeEnumerable.Average, so the result matches the BCL and a sum that overflowslongthrowsOverflowExceptioninstead of returning an inexact average. Thefloatoverloads sum indoublelike the BCL. The element count is along, 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:
InterleaveandMerge(sync and async) acquired the enumerators of all sources before entering thetrythat disposes them, so if theGetEnumeratorof one source threw, the enumerators of the sources before it were never disposed. They are now acquired inside thetryand the ones acquired so far are disposed. - Fix:
Sequence.RepeatRangeandAsyncSequence.RepeatRangereject a negativecountwith anArgumentOutOfRangeExceptionwhen they are called, likeRepeatMaterializedandEnumerable.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 exactlycounttimes. - Documentation: the Funcky.Xunit readme claimed that
AggressiveInliningremoves theFunctionalAssertframe 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 singleFunctionalAssertframe 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.txtandPublicAPI.Unshipped.txtfiles moved toPublicAPI/<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
OrNoneFromTryPatternsource generator handles try methods it previously generated broken code for or crashed on:in,refandref readonlyparameters are forwarded with their modifiers instead of being dropped from the signature, non-null default values (numbers, strings, chars, booleans, enums anddefaultstructs) are copied instead of throwing at generation time, overloads that do not follow the try pattern (no or severaloutparameters, non-boolreturn, 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 theParse…OrNonefamily shows documentation in IntelliSense. - New: the generic
ParseOrNone<T>(string,ReadOnlySpan<char>and UTF-8ReadOnlySpan<byte>) andParseNumberOrNone<T>(string andReadOnlySpan<char>) gain overloads without theIFormatProvider, which use the current culture like the generatedParse…OrNonemethods, andParseNumberOrNone<T>gains overloads without theNumberStyles, which use the number type's default style. - Fix:
ParseNumberOrNone<T>on strings accepts anullcandidate and returnsNone, likeParseOrNone<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 asIReadOnlyList<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 theparamsoverload rather than producing a one-element sequence. - Breaking: the generic
ParseEnumOrNone<TEnum>overloads are constrained tostruct, Enuminstead ofstruct, so"1".ParseEnumOrNone<int>()is a compile error instead of anArgumentExceptionat runtime. - Fix: the
stringoverloads ofParseEnumOrNoneaccept anullcandidate and returnNone, like every otherParse…OrNonemethod on strings. - Fix: on frameworks without
ExceptionDispatchInfo.SetCurrentStackTrace(.NET Framework, .NET Standard, .NET Core 3.1),Result<T>.Errorcaptures 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 firstErrorcall failed with aTypeInitializationException. The lookup is now defensive: if it fails, the stack trace is simply not captured andErrorworks as usual. - Fix:
OptionEqualityComparer<T>.DefaultandOptionComparer<T>.Defaultcreated a new comparer instance on every read, and everyEquals,GetHashCode,==andCompareToon anOption<T>went through them and through several delegate allocations. Both defaults are now cached per item type likeEqualityComparer<T>.Default, and equality, hashing and comparison of options no longer allocate. - Fix:
GetHashCodeandToStringofEither<L, R>andGetHashCodeofEitherOrBoth<L, R>threwNotSupportedExceptionfor a value constructed withdefault, so such a value brokeHashSet,Dictionary,Distinct, string interpolation and logging. Both members are now total likeEqualsalready was: a defaulted value hashes to 0 and prints asUninitialized.Match,Switchand everything built on them still throw. - New:
EitherOrBoth<L, R>overridesToString(Left(…),Right(…),Both(…, …)). - Fix: a
Result<T>constructed withdefaultbehaved likeOkwith anullvalue, bypassing the null check ofResult.Ok.Match,Switchand every operation built on them now throwNotSupportedExceptionfor such a value, likeEither<L, R>already did.Equals,GetHashCodeandToStringstay total. The analyzer λ1009 still reportsdefault(Result<T>)at compile time; see polyadic/funcky#934 for making it a built-in analyzer. - Fix:
ShuffleandShuffleAsyncwithout aRandomargument usednew 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 fromRandom.Sharedon .NET 6 and later and from a shared, per-thread generator on older frameworks. - Fix: on frameworks before .NET 8,
Shufflebuilt its result by appending to anImmutableList, 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
ShuffleandShuffleAsyncclaimed deferred execution; both are eager. - Fix:
MemoizeandCycleRange/RepeatRangeforIEnumerableno 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 fromGetEnumeratordoes 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, andMemoizecan now be enumerated by several threads concurrently. TheIAsyncEnumerablecycle buffer no longer callsMoveNextAsyncon 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
Materializereturns for anIList<T>orICollection<T>source can no longer be used to modify the source through a cast to the mutable interface:IsReadOnlyistrueandAdd,Clear,Remove,Insert,RemoveAtand the indexer setter throwNotSupportedException, likeReadOnlyCollection<T>. The view still reflects later changes to the source;Materializeevaluates a lazy sequence once, it does not take a snapshot. - Fix: the iterator-based
IAsyncEnumerableextensions (AdjacentGroupByand itsAwaitvariants,AnyOrElse,Chunk,Inspect,InspectEmpty,Interleave,Memoize,Merge,Pairwise,PowerSet,InclusiveScan,ExclusiveScanand theirAwaitvariants,SlidingWindow,Split,Transpose,WithFirst,WithLast,WithPrevious,ZipLongest) andAsyncSequence.Successorsnow validate their arguments when they are called, like LINQ: anullsource, selector, comparer or fallback throws anArgumentNullExceptionnaming the parameter immediately, instead of aNullReferenceExceptionwhen the result is first enumerated. - Fix:
WithFirst,WithLastandZipLongestforIAsyncEnumerablenow pass the cancellation token given toGetAsyncEnumerator(e.g. viaWithCancellation) on to the source sequence. - Fix: the iterator-based
IEnumerableextensions (AdjacentGroupBy,AnyOrElse,Chunk,Inspect,InspectEmpty,Interleave,Memoize,Merge,Pairwise,PowerSet,InclusiveScan,ExclusiveScan,SlidingWindow,Split,Transpose,WithFirst,WithLast,WithPrevious,ZipLongest) andSequence.Successorsnow validate their arguments when they are called, like LINQ: anullsource, selector, comparer or fallback throws anArgumentNullExceptionnaming the parameter immediately, instead of aNullReferenceExceptionwhen the result is first enumerated. - Breaking:
SplitonIEnumerable<T>andIAsyncEnumerable<T>now behaves exactly likestring.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:
MinByOrNoneandMaxByOrNonenow treatnullkeys likeEnumerable.MinBy/Enumerable.MaxBy: elements whose key isnullare never chosen as the minimum or maximum, and if all keys arenullthe first element is returned. PreviouslyMinByOrNonereturned the first element with anullkey, because the default comparer ordersnullbefore 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 negativemaxRetrieswith anArgumentOutOfRangeExceptionwhen they are constructed, instead of failing later insideRetry. - Fix:
ConstantDelayPolicy,LinearBackOffRetryPolicyandExponentialBackOffRetryPolicynow reject a negative delay with anArgumentOutOfRangeExceptionwhen they are constructed, instead of failing later fromThread.SleeporTask.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, andFunckyitself on .NET 10):AsyncSequence.Successorsnow honours the cancellation token passed toGetAsyncEnumerator(e.g. viaWithCancellation) and stops before computing the next element.MemoizeandCycleRange/RepeatRangeforIAsyncEnumerableno 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.AnyOrElseforIAsyncEnumerablenow awaits withConfigureAwait(false)like the other operators.- The exception-based
RetryAsync/RetryAwaitAsyncno longer retry once the cancellation token is cancelled, even ifshouldRetrymatches the exception, and check the token before the first attempt.
- New:
RetryAsyncandRetryAwaitAsyncoverloads whose producer receives theCancellationToken, so an attempt in progress can be aborted. - Fix: the
RetryandRetryAsyncoverloads without a retry policy were implemented recursively, so a producer that returnedNoneoften enough crashed the process with aStackOverflowException. They are now plain loops. - Fix: the
Option-basedRetryandRetryAsyncoverloads numbered the retries from 0 while the exception-based overloads numbered them from 1, so the sameIRetryPolicyproduced different delays depending on the overload. With aLinearBackOffRetryPolicytheOption-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:
ExponentialBackOffRetryPolicynow waitsfirstDelaybefore the first retry, likeLinearBackOffRetryPolicy. Previously the first delay wasfirstDelay * 1.5with the exception-basedRetryoverloads. - Fix:
Transposeenumerated 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:
Transposenow throws anInvalidOperationExceptionwhen 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 toMemoryExtensions.Split(ReadOnlySpan<T>, T)because of first-class span conversions, so the result was aSpanSplitEnumerator<T>instead of anIEnumerable<IReadOnlyList<T>>and the following LINQ call failed to compile. An array overload ofSplitnow keeps arrays bound to Funcky. (#893) - Fix:
MergeonIEnumerable<T>andIAsyncEnumerable<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,WithLastandWithPreviouson anIList<T>source returned a collection whoseCopyToskipped the firstarrayIndexelements of the source, so copying it into an array at a non-zero offset (e.g. viaList<T>.AddRangeorInsertRangeon a non-empty list) dropped elements and left default values behind. (#889) - Fix: awaiting an empty
Option<Task>,Option<Task<T>>,Option<ValueTask>orOption<ValueTask<T>>via the awaiter'sOnCompletedmethod never invoked the continuation. Theawaitkeyword was not affected, because the compiler checksIsCompletedfirst, but custom schedulers and other awaiter-based code could hang forever. - The
Optionawaiters now implementICriticalNotifyCompletion, soawaitno longer needs to flow theExecutionContextwhen the underlying task awaiter does not either. - Add a
net10.0target toFuncky.Asyncthat only forwards all its types toFuncky. Projects that reference bothFunckyandFuncky.Asyncno longer get ambiguous type errors (CS0433) when they move to .NET 10. On .NET 10 you can simply remove theFuncky.Asyncreference. - Fix:
InterleaveandMergeforIAsyncEnumerablenow 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
IAsyncEnumerableextensions,AsyncSequence,AsyncFunctionaland the asyncOption/Result/Eitherextensions are part of theFunckypackage itself, becauseSystem.Linq.AsyncEnumerableis now part of the base class library. Funcky does not depend onSystem.Linq.Asyncon this framework. Funcky.Asyncstays the home of these APIs on .NET 9 and older frameworks, where it continues to depend onSystem.Linq.Async.- The
Chunk(int)overload forIAsyncEnumerableis not provided on .NET 10; the identically named method in the base class library is used instead and returns arrays rather thanIReadOnlyList<T>. TheChunkoverloads with aresultSelectorare unchanged. AdjacentGroupByforIAsyncEnumerablereturnsIGrouping<TKey, TElement>on .NET 10 instead of theIAsyncGrouping<TKey, TElement>type fromSystem.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.ParseAssemblyNameInfoOrNoneParseExtensions.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
TryParseAPIs. These changes are reflected in Funcky'sParseOrNoneAPIs.ParseByteOrNoneoverloads withReadOnlySpan<byte>andstring?ParseSByteOrNoneoverloads withReadOnlySpan<byte>ParseSingleOrNoneoverloads withReadOnlySpan<byte>ParseDoubleOrNoneoverloads withReadOnlySpan<byte>ParseDecimalOrNoneoverloads withReadOnlySpan<byte>ParseInt16OrNoneoverloads withReadOnlySpan<byte>ParseInt32OrNoneoverloads withReadOnlySpan<byte>ParseInt64OrNoneoverloads withReadOnlySpan<byte>ParseUInt16OrNoneoverloads withReadOnlySpan<byte>ParseUInt32OrNoneoverloads withReadOnlySpan<byte>ParseUInt64OrNoneoverloads withReadOnlySpan<byte>ParseNumberOrNone<TNumber>overloadsParseOrNone<TParsable>overloads
String Extensions
We implemented a few of the IEnumerable extensions which are very useful on strings.
Chunkonstring.SlidingWindowonstring.
Monads
- Implemented
UpCastfor the mondsOption,Either,ResultandSystem.Lazy. - Implemented
InspectEmptyonIEnumerableandIAsyncEnumerable - Implemented
ToAsyncEnumerableextension onOption
IEnumerator
MoveNextOrNoneextension onIEnumerator<T>
Consistency
FindIndexOrNoneandFindLastIndexOrNoneextensions onList
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 isNone.Either.InspectLeft- executes a side effect only when the either is on theLeftside.Result.InspectError- executes a side effect only when the result is anError.
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.MinByOrNoneEnumerableExtensions.MaxByOrNoneImmutableListExtensions.IndexOfOrNoneImmutableListExtensions.LastIndexOfOrNoneListExtensions.IndexOfOrNone
Funcky 3.1.0 | Funcky.Async 1.1.0 | Funcky.Analyzers 1.2.0
New APIs
- ✨
OptionExtensions.ToNullable✨ StreamExtensions.ReadByteOrNone- New overloads for
ElementAtOrNonethat take anIndex. - New overload for
JoinToStringthat takes anIEnumerable<string>.
.NET 7
- .NET 7 added new overloads to their
TryParseAPIs. These changes are reflected in Funcky'sParseOrNoneAPIs. - The
ParseOrNonemethods 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.GetOrThrowEnumerableExtensions.GetNonEnumeratedCountOrNone
PriorityQueue
PriorityQueueExtensions.DequeueOrNonePeekOrNone
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.
ParseIntOrNonehas been renamed toParse) - 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.
MaterializeChunkPartitionShuffleSlidingWindowSplitTransposeSequence.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
Matchfunctions now differentiate betweenFuncandAction. TheActionoverloads have been renamed toSwitch. - The return type of
EnumerableExtensions.ForEachhas been changed toUnit. - Many parameter names and generic type names have been renamed in an attempt to unify naming across Funcky.
- All
Actionextensions have been moved to a new classActionExtensions. EitherOrBothhas been moved to theFunckynamespace.- The retry policies have been moved to the
Funcky.RetryPoliciesnamespace. Partitionreturns a customPartitionsstruct instead of a tuple.
Obsoleted APIs Removed
APIs that have been obsoleted during 2.x have been removed:
ObjectExtensions.ToEnumerableFuncky.GenericConstraints.RequireClassandRequireStruct- All
Try*APIs (TryGetValue,TryParse*, etc.). These APIs use theOrNonesuffix instead. Sequence.Generatehas been superseded bySequence.SuccessorsCartesianProduct
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:
AsyncEnumerableExtensionsFunctional.RetryAsync->AsyncFunctional.RetryAsyncOption<Task>andOption<ValueTask>awaiters
Funcky.Async
AsyncSequence
This class exposes all of the same factory functions as Sequence, but for IAsyncEnumerable:
ReturnSuccessorsConcatCycleCycleRangeFromNullableRepeatRange
New IAsyncEnumerable extensions
We've worked hard towards the goal of parity between our extensions for IEnumerable and IAsyncEnumerable:
AdjacentGroupByAnyOrElseAverageOrNoneAsync/MaxOrNoneAsync/MinOrNoneAsyncChunkConcatToStringAsyncExclusiveScanInclusiveScanInspectInterleaveIntersperseJoinToStringAsyncMaterializeAsyncMemoizeMergeNoneAsyncPartitionAsyncPowerSetSequence/SequenceAsync/Traverse/TraverseAsyncShuffleAsyncSlidingWindowSplitTransposeWhereNotNullWithIndex/WithLast/WithPrevious/WithFirstZipLongest
Funcky.Xunit
- Breaking: The
Isprefix 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 theOption.None<T>method as a future proof replacement toOption<T>.Nonefor use in method groups, because Funcky 3 changesOption<T>.Noneto 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.CartesianProductwill be removed in Funcky 3.- To align our naming with that of the BCL, the
ParseOrNonemethods that return a type that has a keyword in C#int,long, etc. use the name of the BCL type instead.
Example:ParseIntOrNonebecomesParseInt32OrNone.
The old methods will be removed in Funcky 3. - In preparation for Funcky 3 we deprecated
Option<T>.Nonewhen used as method group. UseOption.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,TimeOnlyVersion- Support for
ReadOnlySpan<T>as input - ... and more
Convenience for Either and Result
- Added implicit conversions for
EitherandResult. - Implement
InspectforEitherandResult. - Added
PartitionforIEnumerable<Either>andIEnumerable<Result>. - Added
ToStringonEitherandResult. - Implement
ToEitheronOption.
IEnumerable<T> extensions
AnyOrElse- Prefix sum:
InclusiveScanandExclusiveScan
Analyzers
This release adds two new analyzer rules:
- λ1003: Warning when certain methods, such as
Matchare 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
Funckynamespace, instead ofFuncky.Xunit. - Add assertion methods for testing
Either:FunctionalAssert.IsLeftandFunctionalAssert.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
DequeueOrNoneandPeekOrNoneonQueueandConcurrentQueue. - Add extension
ConcatToStringas an alias forstring.Concat. - Add overload to
WhereSelectwith no parameter. - Add methods to convert from
EithertoOption: #439LeftOrNone: Returns the left value orNoneif the either value was right.RightOrNone: Returns the right value orNoneif the either value was left.
- Extension functions for
System.Rangeto allow the generations ofIEnumerable<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.Returnnow accepts multiple parameters:Sequence.Return(1, 2, 3)- ⚠️
Sequence.Generatehas been deprecated in favour of the newly addedSequence.Successorsfunction which includes the first element (seed) in the generated sequence.
Improvements to Option
- Add
Option.FromBooleanto create anOption<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.Flipto swaps left with right.
Tooling
- Funcky automatically adds global usings for the most important namespaces of funcky
when the
FunckyImplicitUsingsproperty 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
Funckypackage 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 forstring.Join.WithPrevious: Similar toWithFirst/Last/Indexbut with the predecessor of each item.ForEach: Add an overload toForEachthat accepts aUnit-returningFunc.
Additional Factory Methods
EitherOrBoth.FromOptionscreates anEitherOrBothfrom two options.Lazy.FromFunccreates aLazy<T>from aFunc.
This is sugar over theLazy<T>constructor, with the additional benefit of supporting type inference.Lazy.Returncreates aLazy<T>from a value.
This is sugar over theLazy<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 timesSequence.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 generatorSequence.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 subsetsShuffle: 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
CurryUncurryFlipCompose
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.ReturnEither<TLeft>.ReturnResult.Return
OptionEqualityComparer
To support more advanced comparison scenarios, OptionEqualityComparer has been added similar to the already existing OptionComparer.
Smaller Improvements
- Added a missing
Matchoverload toEitherthat takesActions - Added additional overloads for
Functional.TrueandFunctional.Falsefor up to four parameters.
Funcky 2.3.0
net5.0has been added to Funcky's target frameworks.
Improvements to Option<T>
Option<T>is now implicitly convertible fromT.public static Option<int> Answer => 42;Optionadds support forSystem.Text.Json:
The customJsonConverteris picked up automatically when serializing/deserializing.Noneis serialized asnullandSome(value)is serialized to whatevervalueserializes to.
Factory methods for IEnumerable<T>
This release adds factory methods for creating IEnumerable<T>
with the static class Sequence:
Sequence.Return: Creates anIEnumerable<T>with exactly one item.Sequence.FromNullable: Creates anIEnumerable<T>with zero or one items.Sequence.Generate: Creates anIEnumerable<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>:
AdjacentGroupByAverageOrNoneCartesianProductChunkElementAtOrNoneInterleaveMaxOrNoneMergeMinOrNonePairwisePartitionSlidingWindowTakeEveryTransposeWithFirstWithIndexWithLast
IAsyncEnumerable<T> Support
This release adds a couple of extension methods that provide interoperability
with Option<T> to IAsyncEnumerable<T>:
WhereSelectFirstOrNoneAsyncLastOrNoneAsyncSingleOrNoneAsyncElementAtOrNoneAsync
A couple of the new extension methods on IEnumerable<T> have async counterparts:
PairwiseTakeEvery
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:
FirstOrNoneLastOrNoneSingleOrNone
Dependencies
To support .NET Standard, Funcky conditionally pulls in dependencies that provide the missing functionality:
Microsoft.Bcl.AsyncInterfacesfor .NET Standard 2.0System.Collections.ImmutableandSystem.Text.Jsonfor .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 everywhereawaitis used.- The
IRetryPolicyimplementations now use correctTimespanwithdoublemultiplication when targeting .NET Standard 2.0.
Deprecations
ObjectExtensions.ToEnumerablehas been deprecated in favor ofSequence.FromNullable.RequireClassandRequireStructhave been obsoleted with no replacement.
Funcky 2.2.0 | Funcky.xUnit 0.1.3
- Added overload to
Functional.Retrywith aIRetryPolicy. - Added
Noneoverload 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
Inspectmethod toOptionakin toIEnumerable.Inspect. - Add
ToTheoryDataextension forIEnumerable<T>for xUnit. - Add
Unit.Valueas a way to a get aUnitvalue. - Add
Functional.Retrywhich retries a producer untilOption.Someis returned.
Funcky 2.0.0
Breaking Changes
- Remove
Readermonad based onawait. - Remove
IToString. - Remove overload for
Option.Fromthat flattens passedOptions. - Move
ToEnumerableextension 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 changeEnumerableExtensions.ToEnumerabletoObjectExtensions.ToEnumerable. - Rename
Option.FromtoOption.FromNullableand remove overload that takes non-nullable value types. - Unify
Option<T>.ToEnumerableandYieldtoToEnumerable - Rename
OrElseoverloads that return the item toGetOrElsewhich improves overload resolution. - The
Eachextension method onIEnumerable<T>has been renamed toForEach. - Move the
Okconstructor ofResult<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 ofPredicate<T>in predicate composition functions (Functional.All,Functional.Any,Functional.Not), because most APIs inSystemuseFunc. Functional.Anynow returnsfalsewhen the given list of predicates is empty.
Fixes
- Fix incorrect
Equalsimplementation onOption.Equalspreviously returnedtruewhen comparing aNonevalue with aSomevalue containing the default value of the type. Exceptioncreated byResultmonad contains valid stack trace- Fix incorrect implementation on
Result.SelectManywhich called theselectedResultSelectoreven when the result was an error. As a result (pun intended) of the fix,ResultCombinationExceptionis no longer needed and also removed.
Additions
- Add
IndexOfOrNone,LastIndexOfOrNone,IndexOfAnyOrNoneandLastIndexOfAnyOrNoneextension methods tostring. - Added
Curry,UncurryandFlipto theFunctionalClass - Add extension method for
HttpHeaders.TryGetValues, which returns anOption. - Add extension methods for getting
Streamproperties that are not always available, asOption:GetLengthOrNone,GetPositionOrNone,GetReadTimeoutOrNone,GetWriteTimeoutOrNone. - Add
Noneextension method toIEnumerable. Option<Task<T>>,Option<Task>and theirValueTaskequivalents 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
IEquatableonOption,ResultandEither.
Funcky 2.0.0-rc.2
- Move the
Okconstructor ofResult<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,IndexOfAnyOrNoneandLastIndexOfAnyOrNoneextension methods tostring. - Rename
OrElseoverloads that return the item toGetOrElsewhich improves overload resolution. - Added
Curry,UncurryandFlipto theFunctionalClass - Remove
IToString. - Mark our functions as
[Pure]. - Fix incorrect implementation on
Result.SelectManywhich called theselectedResultSelectoreven when the result was an error. As a result (pun intended) of the fix,ResultCombinationExceptionis no longer needed and also removed.
Funcky 2.0.0-rc.1
- Full nullable support introduced with C# 8
- Rename
Option.From->Option.FromNullableand remove overload that takes non-nullable value types. - Use
Func<T, bool>instead ofPredicate<T>in predicate composition functions (Functional.All,Functional.Any,Functional.Not), because most APIs inSystemuseFunc. Functional.Anynow returnsfalsewhen the given list of predicates is empty.- The
Eachextension method onIEnumerable<T>has been renamed toForEach. - Unify
Option<T>.ToEnumerableandYieldtoToEnumerable - Remove
Readermonad based onawait. Exceptioncreated byResultmonad contains valid stack trace
Funcky 1.8.0
- Added overload for
AndThenwhich flattens theOption - Add
Wheremethod toOption<T>, which allows filtering theOptionby a predicate. - Add overload for
Option<T>.SelectManythat takes only a selector. - Add
WhereNotNullextension method forIEnumerable<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.Matchnow throws when called on anEithervalue created usingdefault(Either<L, R>).- Add
TrueandFalsefunctions to public API - Match of
ResultMonad accepts actions - Add
FirstOrNone,LastOrNoneandSingleOrNoneextension functions
Funcky 1.6.0
- Add ToEnumerable function to
Option<T>. - Add
WhereSelectextension function forIEnumerable<T>. - Add missing overload for nullary actions to
ActionToUnit.