A lightweight functional programming library that brings expressive, composable, and safe abstractions to C#. Less ceremony, more clarity.
- 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
Funk is available as a NuGet package.
dotnet add package Funk
Supports: .NET 8+, .NET Standard 2.0 / 2.1
Add the namespace and optionally import the Prelude for terse factory functions:
usingFunk;usingstaticFunk.Prelude;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.
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");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"));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"));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();C# 9 records with with expressions solve a similar problem — creating modified copies — but fall short for EF Core entity modeling:
| Concern | C# Records + with | Data<T> + With/Build |
|---|---|---|
| Copy depth | Shallow (shared references) | Deep (independent graph) |
| Nested modification | Cascading with per level | Single expression, any depth |
| Type hierarchies | No F-bounded polymorphism | Full CRTP support |
| Return type in hierarchies | Base type in generic code | Concrete derived type |
| EF Core private setters | Uses init (reflection-dependent) | Uses private set (EF Core standard) |
| Builder pattern | Not available | Fluent, batched, one deep-copy |
| Navigation property safety | References shared after with | Deep-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.
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>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");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;Transform values through fluent pipelines:
varresult="hello".Do(s =>s.ToUpper()).Do(s =>$"{s}!");// "HELLO!"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);// 8Apply 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 "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"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 mergedFor full API documentation, visit the Funk documentation site.
This project is licensed under the MIT License — see the LICENSE file for details.
