Skip to content

Repository files navigation

VersionBuild & Test

Funk — Functional C#

A lightweight functional programming library that brings expressive, composable, and safe abstractions to C#. Less ceremony, more clarity.

Features

  • Maybe<T> — explicit nullability without null reference exceptions
  • Exc<T, E> — railway-oriented error handling with success, failure, and empty states
  • OneOf<T1, …, T5> — type-safe discriminated unions with exhaustive matching
  • Record<T1, …, T5> — immutable products with safe deconstruction and mapping
  • Pattern<R> — lazy, expression-based pattern matching (sync and async)
  • Data<T> & Builder<T> — fluent immutable object updates
  • Prelude — terse factory functions (may, rec, list, …)
  • Extensions — functional combinators on objects, tasks, enumerables, and actions

Installation

Funk is available as a NuGet package.

dotnet add package Funk

Supports: .NET 8+, .NET Standard 2.0 / 2.1

Usage

Add the namespace and optionally import the Prelude for terse factory functions:

usingFunk;usingstaticFunk.Prelude;

A note on Nullable Reference Types

Funk deliberately does not adopt nullable reference types (NRTs). Unlike nullable value types — where int? is Nullable<int>, a real generic struct enforced at both compile time and runtime — string? is not a distinct type. It is string at the IL level, with erasable compiler annotations that produce warnings but no runtime guarantees. null can still flow into a string parameter through reflection, interop, older libraries, or explicit suppression with null!.

Maybe<T> is the type-level solution: a readonly struct that forces the caller to Match or Map to extract the value. The empty case is structurally irrepresentable as a bare T — you must handle it. This is encoding invariants in the type system, not relying on advisory compiler hints.

Maybe<T>

Represent the possible absence of a value — no more nulls.

// Create from a value or nullMaybe<string>name=may("Funk");// NotEmptyMaybe<string>none=may<string>(null);// IsEmpty// Pattern match to safely extractstringgreeting=name.Match(
_ =>"No name provided",
n =>$"Hello, {n}!");// Map — transform plain values while staying in MaybeMaybe<int>length=name.Map(n =>n.Length);// Maybe<int> = 4// FlatMap — chain operations that themselves return MaybeMaybe<UserProfile>GetProfile(stringname)=>profiles.Get(name);Maybe<Theme>GetTheme(GuidthemeId)=>themes.Get(themeId);vartheme=name.FlatMap(n =>GetProfile(n)).FlatMap(p =>GetTheme(p.ThemeId));// Maybe<Theme>// Get with a fallbackstringvalue=name.GetOr(_ =>"default");

Exc<T, E>

Railway-oriented error handling — operations that can succeed, fail, or be empty.

// Wrap an operation that might throwExc<int,FormatException>parsed=Exc.Create<int,FormatException>(
_ =>int.Parse("42"));// Map — transform plain values while keeping exception safetyExc<string,FormatException>result=parsed.Map(n =>$"The answer is {n}");// FlatMap — chain operations that themselves return ExcExc<Customer,DbException>GetCustomer(Guidid)=>Exc.Create<Customer,DbException>(_ =>db.Find(id));Exc<Account,DbException>GetAccount(GuidaccountId)=>Exc.Create<Account,DbException>(_ =>db.FindAccount(accountId));varaccount=GetCustomer(id).FlatMap(c =>GetAccount(c.AccountId));// Exc<Account, DbException>// Pattern match all three statesstringmessage=parsed.Match(ifEmpty: _ =>"Nothing to parse",ifSuccess: n =>$"Parsed: {n}",ifFailure: e =>$"Error: {e.Root}");// Deconstruct into success and failure as Maybe valuesvar(success,failure)=parsed;// success: Maybe<int>, failure: Maybe<EnumerableException<FormatException>>// Recover from failure with OnFailure — chain fallbacksvarconfig=Exc.Create<Config,IOException>(_ =>LoadConfigFromFile()).OnFailure(e =>LoadConfigFromNetwork()).OnFailure(e =>GetDefaultConfig());// OnFlatFailure when the recovery itself returns an Excvardata=Exc.Create<string,DbException>(_ =>db.GetFromPrimary()).OnFlatFailure(e =>Exc.Create<string,DbException>(_ =>db.GetFromReplica()));// OnEmpty — recover when result is empty (distinct from failure)varresult=Exc.Create<Config,IOException>(_ =>LoadConfigFromFile()).OnFailure(e =>GetNullableConfig()).OnEmpty(_ =>GetDefaultConfig());// Async variantsvarasyncResult=awaitExc.CreateAsync<string,HttpRequestException>(
_ =>httpClient.GetStringAsync("https://api.example.com/data")).OnFailureAsync(e =>httpClient.GetStringAsync("https://api.example.com/fallback"));

Pattern matching

Lazy, expression-based matching with collection initializer syntax:

intstatusCode=404;// Value-based matchingstringstatus=newPattern<string>{(200, _ =>"OK"),(404, _ =>"Not Found"),(500, _ =>"Internal Server Error")}.Match(statusCode).GetOr(_ =>"Unknown");// Predicate-based matchingstringrange=newPattern<string>{(x =>x<200,(int_)=>"Informational"),(x =>x<300,(int_)=>"Success"),(x =>x<400,(int_)=>"Redirection"),(x =>x<500,(int_)=>"Client Error")}.Match(statusCode).GetOr(_ =>"Server Error");// Type-based matchingobjectshape=newCircle(5);stringdescription=newTypePattern<string>{(Circlec)=>$"Circle with radius {c.Radius}",(Squares)=>$"Square with side {s.Side}"}.Match(shape).GetOr(_ =>"Unknown shape");// Async pattern matchingstringbody=awaitnewAsyncPattern<string>{(200, _ =>httpClient.GetStringAsync("/ok")),(404, _ =>httpClient.GetStringAsync("/not-found"))}.Match(statusCode).GetOrAsync(_ =>Task.FromResult("Fallback"));// Async type-based matchingstringinfo=awaitnewAsyncTypePattern<string>{(Circlec)=>ComputeAreaAsync(c),(Squares)=>ComputeAreaAsync(s)}.Match(shape).GetOrAsync(_ =>Task.FromResult("Unknown shape"));

Data<T> & Builder<T>

Fluent immutable updates — create modified copies without mutation. Ideal for domain models and ORM entities.

Data<T> uses the CRTP (Curiously Recurring Template Pattern). For type hierarchies, use F-bounded polymorphism — make the base class generic in its derived type so that With/Build return the concrete type:

publicinterfaceIEntity{GuidId{get;}}publicabstractclassEntity<T>:Data<T>,IEntitywhereT:Entity<T>{[Key]publicGuidId{get;privateset;}=Guid.NewGuid();[Required]publicDateTimeCreatedAt{get;privateset;}=DateTime.UtcNow;[Required]publicDateTimeModifiedAt{get;privateset;}=DateTime.UtcNow;[Required]publicGuidCreatedBy{get;privateset;}[Required]publicGuidModifiedBy{get;privateset;}[Required,Min(1)]publicuintVersion{get;privateset;}=1;publicIEntityWithVersion(uintversion){Version=version;returnthis;}}publicsealedclassAccount:Entity<Account>{[Required,MaxLength(255)]publicstringEmailAddress{get;privateset;}[Required,MaxLength(50)]publicstringStatus{get;privateset;}[Required,MaxLength(50)]publicstringType{get;privateset;}privateAccount(){}publicstaticAccountNew=>new();}

The constraint where T : Entity<T> is stricter than where T : Data<T>. While Data<T> is the minimum required for With/Build to work, using Entity<T> as the bound ensures that T is specifically part of the Entity hierarchy — not just any Data<T>. Since Entity<T> extends Data<T>, any T satisfying Entity<T> automatically satisfies Data<T> through inheritance, so the With/Build mechanism works unchanged.

// Build a new entity — With/Build returns Account, not Entityvaraccount=Account.New.With(a =>a.EmailAddress,"alice@example.com").With(a =>a.Status,"Active").With(a =>a.Type,"Personal").With(a =>a.CreatedBy,adminId).With(a =>a.ModifiedBy,adminId).Build();// Account — not Entity// Create a modified copy — original is unchangedvarupdated=account.With(a =>a.Status,"Suspended").With(a =>a.ModifiedAt,DateTime.UtcNow).Build();

Why Data<T> over C# records for EF Core entities

C# 9 records with with expressions solve a similar problem — creating modified copies — but fall short for EF Core entity modeling:

ConcernC# Records + withData<T> + With/Build
Copy depthShallow (shared references)Deep (independent graph)
Nested modificationCascading with per levelSingle expression, any depth
Type hierarchiesNo F-bounded polymorphismFull CRTP support
Return type in hierarchiesBase type in generic codeConcrete derived type
EF Core private settersUses init (reflection-dependent)Uses private set (EF Core standard)
Builder patternNot availableFluent, batched, one deep-copy
Navigation property safetyReferences shared after withDeep-copied, independent

The with expression performs a shallow member-wise copy — if an entity has navigation properties (collections, references), the original and the copy share the same objects. This causes change tracking conflicts when both are used with a DbContext. Data<T> performs deep copying, producing a completely independent object graph.

Records also cannot express F-bounded polymorphism (Entity<T> : Data<T> where T : Entity<T>), meaning with on a base record returns the base type, not the concrete derived type. Data<T> preserves the concrete type through the entire With/Build chain.

For a detailed analysis, see the Data documentation.

OneOf<T1, …, T5>

Type-safe discriminated unions:

// A value that is either a string or an intvarresult=newOneOf<string,int>("hello");stringoutput=result.Match(
_ =>"empty",
s =>$"String: {s}",
n =>$"Number: {n}");// Access individual states safely via MaybeMaybe<string>asString=result.First;// NotEmptyMaybe<int>asInt=result.Second;// IsEmpty// Deconstruct into Maybe valuesvar(first,second)=result;// first: Maybe<string>, second: Maybe<int>

Record<T1, …, T5>

Immutable products with safe deconstruction and mapping:

// Create a record using the Preludevarperson=rec("Alice",30);// Deconstructvar(name,age)=person;// Map to a new recordvarupdated=person.Map((n,a)=>(n.ToUpper(),a+1));// Match to extract a resultstringdescription=person.Match((n,a)=>$"{n} is {a} years old");

LINQ query syntax

Maybe and Exc support C# query expressions for composing operations naturally:

// Maybe — compose multiple lookupsMaybe<string>city=fromuserinFindUser("alice")fromaddressinuser.Address.AsMaybe()whereaddress.Country=="US"selectaddress.City;// Exc — chain operations that can failExc<decimal,Exception>total=fromorderinLoadOrder(orderId)fromdiscountinApplyDiscount(order)selectorder.Amount-discount;

Piping

Transform values through fluent pipelines:

varresult="hello".Do(s =>s.ToUpper()).Do(s =>$"{s}!");// "HELLO!"

Currying

Transform multi-parameter functions into chains of single-parameter functions:

Func<int,int,int>add=(a,b)=>a+b;varcurriedAdd=add.Curry();// Func<int, Func<int, int>>varaddFive=curriedAdd(5);// Func<int, int>varresult=addFive(3);// 8

Partial application

Apply arguments one at a time, reducing arity at each step:

Func<string,int,string>repeat=(s,n)=>string.Concat(Enumerable.Repeat(s,n));varrepeatHello=repeat.Apply("hello ");// Func<int, string>varresult=repeatHello(3);// "hello hello hello "

Function composition

Combine functions into pipelines with ComposeLeft (left-to-right) and ComposeRight (right-to-left):

Func<string,int>parse=int.Parse;Func<int,string>format= n =>$"Number: {n}";varpipeline=parse.ComposeLeft(format);// Func<string, string>varresult=pipeline("42");// "Number: 42"

Applicative validation

Accumulate all errors instead of short-circuiting on the first failure:

// Apply — short-circuits on first failure (monadic)success<Func<string,int,User>,ValidationException>(createUser).Apply(ValidateName(input))// fails → stops.Apply(ValidateAge(input));// never checked// Validate — collects ALL failures (applicative)success<Func<string,int,User>,ValidationException>(createUser).Validate(ValidateName(input))// fails → keeps going.Validate(ValidateAge(input));// also checked → both errors merged

Documentation

For full API documentation, visit the Funk documentation site.

License

This project is licensed under the MIT License — see the LICENSE file for details.