UniFP is a GC zero-allocation C# functional programming framework for Unity, inspired by Rust, Haskell, and F#. It brings functional thinking and explicit error handling to game logic without hurting runtime performance.
Traditional C# functional libraries (for example, language-ext) target general-purpose .NET environments. They ship extensive features and complex abstractions, with steep learning curves and often avoid using structs, leading to GC allocations and performance loss in Unity's runtime.
UniFP was developed as a lightweight alternative optimized for real-time applications, combining Rust's type system-based stability and performance-first philosophy with the railway-oriented programming paradigm from functional languages. The goal is to enable safe error handling and declarative pipelines in gameplay code without heavy dependencies.
Result<T> and Option<T> provide pipeline extensions that implement type-safe flow control instead of exceptions while minimizing GC burden.
Every core type is provided as a struct. In the Editor or
UNIFP_DEBUGenvironment, each operation automatically records file/line/method information. It can be used directly in Unity projects without additional configuration.
What UniFP is NOT
❌ Rewriting all Unity scripts in functional style 🙅♂️🙅♂️🙅♂️🙅♂️🙅♂️🙅♂️🙅♂️
✅ Simplifying complex branching and error handling in existing logic with functional pipelines 🙆♂️🙆♂️🙆♂️🙆♂️🙆♂️🙆♂️🙆♂️🙆♂️
- Core Highlights
- Comparing with Other Libraries
- Getting Started
- Core Concepts
- Fluent Pipelines
- Async Support (UniTask / Awaitable)
- Resilience Utilities
- Debugging & Observability
- Performance Toolkit
- Sample Scenes & Tests
- Documentation
- Contributing
- License
Result<T>andOption<T>structs implement explicit success/failure and null safety without heap allocation.- Railway-style extension methods (
Then,Map,Filter,Recover,DoStrict,IfFailed, etc.) provide highly readable pipelines. - Async support for both UniTask and Unity Awaitable with
.ThenAsync,.MapAsync,.FilterAsync, andAsyncResult.TryAsync()utilities. - ResultCombinators and collection extensions for combining multiple results or traversing lists/Span with conditional validation.
- SafeExecutor instrumentation automatically records operation types and call locations in Editor/debug environments.
- Performance-focused utilities like DelegateCache, ResultPool, and SpanExtensions suppress GC even in high-frequency code.
Assets/Scenesdemos andsrc/UniFP/Assets/Testsunit tests showcase real usage patterns you can verify immediately.
Performance Comparison:
- ✅ Zero-GC struct design (all core types stack-allocated)
- ✅ Delegate caching for Update loop optimization
- ✅ Built-in ResultPool & ListPool
Feature Comparison:
- UniFP's
Then= NOPE'sBind(C#-friendly naming) - Additional features: Retry, RetryWithBackoff, Trace, Breakpoint
- Advanced collection extensions: SelectResults, CombineAll, Partition
Error Typing:
UniFP uses ErrorCode struct, optimized for 99% of Unity game scenarios.
For complex domain logic requiring type-safe errors, custom ErrorCode patterns are available.
➡️ Detailed Comparison: Library Comparison Documentation
Why Not Use language-ext Directly in Unity?
- ❌ No Unity runtime optimization (class-based, GC pressure)
- ❌ Overwhelming feature complexity (100+ monads, Higher-kinded types)
- ❌ Steep learning curve (Haskell background required)
- ✅ UniFP: Unity-specific minimal set, start with C# LINQ experience only
Quick Comparison:
| Category | language-ext | UniFP |
|---|---|---|
| Core Monads | Option, Either, Try, Validation, Fin | Result, Option, NonEmpty |
| Performance | High abstraction overhead | Zero-GC structs, pooling optimization |
| Learning Curve | Steep (Haskell) | Gentle (C# LINQ) |
| Unity Integration | Limited | UniTask/Awaitable native support |
➡️ Detailed Comparison: Library Comparison Documentation
-
Open Window ▸ Package Manager in Unity.
-
Select Add package from git URL... and enter the address below.
https://github.com/Nekoya-Jin/UniFP.git?path=src/UniFP/Assets/Plugins/UniFP -
Unity will install the
com.unifp.corepackage and add folders with examples and asmdef.
To modify Packages/manifest.json directly, add the following dependency:
{
"dependencies": {
"com.unifp.core": "https://github.com/Nekoya-Jin/UniFP.git?path=src/UniFP/Assets/Plugins/UniFP"
}
}UniFP works standalone, but you can enhance async functionality by installing one of the following:
Option 1: UniTask (Recommended for Unity 2022.3+)
- More features and better performance than Unity's Awaitable
- Install via UPM:
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask - Enables
AsyncResult.ThenAsync,MapAsync,FilterAsync,DoAsync,TryAsync
Option 2: Unity Awaitable (Unity 6.0+)
- Built into Unity 6.0+, no installation needed
- Automatically detected via
versionDefinesin UniFP.asmdef - Provides the same async API as UniTask
Without async support:
- All synchronous
Result<T>features work perfectly - Async extensions won't be available
Result<T> expresses success (Success) or failure (Failure) as types, freeing you from if/else and try/catch hell.
Have you ever encountered code like this? An if containing a try, which contains another if-else... Success logic, failure logic, exception handling, and default value assignments are tangled like spaghetti, making it hard to know where to start reading. The moment you add one more validation step, the hell gets deeper and eventually becomes code no one wants to touch.
using UnityEngine;
public class LoginSample : MonoBehaviour
{
void Start()
{
var userId = PlayerPrefs.GetString("userId");
if (string.IsNullOrWhiteSpace(userId))
{
Debug.LogError("Login failed: input is empty");
userId = "guest";
}
else
{
try
{
if (!ValidateAccount(userId))
{
Debug.LogWarning("Login failed: user not found");
userId = "guest";
}
else
{
Debug.Log($"Login succeeded: {userId}");
LogUser(userId);
}
}
catch (System.Exception ex)
{
Debug.LogError($"Exception during login: {ex.Message}");
userId = "guest";
}
}
}
bool ValidateAccount(string id) => id == "player42";
void LogUser(string id) => Debug.Log($"Auth pipeline accepted {id}");
}- State checks, exception handling, and default value recovery logic are scattered across if/else and try/catch, making the branching complex.
- As failure cases increase, the number of branches grows exponentially, making maintenance difficult.
Now let's solve this problem with UniFP. UniFP places all branches and exception handling on a single conveyor belt, showing explicit success/failure paths. Data flows straight along the success highway, and if a problem arises anywhere, it immediately exits to the failure lane. Code reads from top to bottom like flowing water, and each step clearly shows what it does.
// Good example: All steps are clearly chained.
using UniFP;
using UnityEngine;
public class LoginSample : MonoBehaviour
{
void Start()
{
var loginResult = Result.FromValue(PlayerPrefs.GetString("userId"))
// 1. Is the input valid? (If not, jump to InvalidInput failure lane)
.Filter(DelegateCache.IsNotNullOrWhitespace, ErrorCode.InvalidInput)
// 2. Does the account exist? (If not, jump to NotFound failure lane)
.Then(id => ValidateAccount(id)
? Result<string>.Success(id)
: Result<string>.Failure(ErrorCode.NotFound))
// 3. (Only while on the success highway) Log the user
.Do(LogUser)
// 🚨 If we exited to the failure lane, the final destination is "guest"
.Recover(_ => "guest");
// Final processing based on result
loginResult.Match(
onSuccess: id => Debug.Log($"Login succeeded: {id}"),
onFailure: code => Debug.LogError($"Login failed: {code}"));
}
bool ValidateAccount(string id) => id == "player42";
void LogUser(string id) => Debug.Log($"Auth pipeline accepted {id}");
}- Each step is explicitly chained, making the success/failure flow obvious at a glance.
- On failure, the pipeline automatically routes to
Recover, cleanly separating exception and default value recovery logic. - Additional validations or async calls can be easily extended with
Then,Filter,ThenAsync, etc.
using UniFP;
// 1. Create directly with Success / Failure
var success = Result<int>.Success(42);
var failure = Result<int>.Failure(ErrorCode.NotFound);
var failureWithMsg = Result<int>.Failure(ErrorCode.ValidationFailed, "Age must be greater than 0");
// 2. Promote a value to Result with FromValue
var fromValue = Result.FromValue(userId);
// 3. Convert exceptions to Result with Try
var parseResult = Result.Try(() => int.Parse(input));
var parseWithCode = Result.Try(() => int.Parse(input), ErrorCode.InvalidInput);// Then: Chain Result-returning functions
Result<User> LoadUser(int id) => /* ... */;
var result = Result.FromValue(42)
.Then(LoadUser); // int -> Result<User>
// Map: Transform regular value-returning functions
var doubled = Result.FromValue(10)
.Map(x => x * 2); // int -> int (automatically wrapped in Result<int>)
// Filter: Conditional validation (Failure on condition fail)
var validated = Result.FromValue(age)
.Filter(x => x >= 18, ErrorCode.ValidationFailed, "Adults only");💡 Tip: Then vs Map
Thenis used for functions that return Result (operations that can fail)Mapis used for functions that return regular values (simple transformations)
// Recover: Replace failure with default value
var withDefault = LoadConfig()
.Recover(code => DefaultConfig);
// IfFailed: Execute alternative pipeline on failure
var cached = LoadFromServer()
.IfFailed(() => LoadFromCache());
// Catch: Intercept and recover from specific errors
var result = LoadResource()
.Catch(ErrorCode.NotFound, () => CreateDefault());
// Match: Different handling based on success/failure
result.Match(
onSuccess: user => Debug.Log($"Welcome, {user.Name}"),
onFailure: code => Debug.LogError($"Load failed: {code}"));// Do: Execute side effect only on success (skip on failure)
var result = LoadUser(id)
.Do(user => Analytics.Track("UserLoaded", user.Id))
.Do(user => Debug.Log($"Loaded: {user.Name}"));
// DoStrict: Abort pipeline if side effect fails
var saved = CreateUser(data)
.DoStrict(user => SaveToDatabase(user)); // Entire operation fails if DB save fails
// IfFailed: Execute side effect only on failure
var result = Process()
.IfFailed(code => Debug.LogError($"Processing failed: {code}"));// ThenIf / MapIf: Selectively transform based on condition
var result = LoadUser(id)
.ThenIf(
condition: user => user.IsPremium,
thenFunc: user => LoadPremiumData(user),
elseFunc: user => Result<UserData>.Success(user.BasicData));
var processed = Result.FromValue(input)
.MapIf(
condition: x => x > 100,
thenFunc: x => x / 2,
elseFunc: x => x);using Cysharp.Threading.Tasks; // or using UnityEngine; (Awaitable)
// ThenAsync: Async Result chaining
async UniTask<Result<User>> LoadUserAsync(int id)
{
return await Result.FromValue(id)
.Filter(x => x > 0, ErrorCode.InvalidInput)
.ThenAsync(async id => await FetchFromAPI(id))
.MapAsync(json => ParseUser(json))
.FilterAsync(user => UniTask.FromResult(user.IsActive), "Inactive user");
}
// TryAsync: Convert exception-throwing async work to Result
var result = await AsyncResult.TryAsync(async () =>
{
var response = await httpClient.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}, ErrorCode.NetworkError);
// DoAsync: Async side effects
var saved = await LoadUser(id)
.DoAsync(async user => await SaveToCloud(user));Option<T> expresses has value (Some) or no value (None) as types, freeing you from null hell.
In Unity projects, you sometimes encounter code with dozens of lines of null checks. if (foo == null) → else if (foo.Bar == null) → else if (foo.Bar.Length == 0) ... Error logs pop up everywhere, and you waste time tracking down which branch threw a NullReferenceException.
public class UserProfileLoader
{
public void Load()
{
var raw = PlayerPrefs.GetString("profile");
if (string.IsNullOrEmpty(raw))
{
Debug.LogWarning("No profile: applying defaults");
ApplyDefaults();
return;
}
var profile = JsonUtility.FromJson<UserProfile>(raw);
if (profile == null || profile.Items == null || profile.Items.Length == 0)
{
Debug.LogError("Profile corrupted: attempting recovery");
ApplyDefaults();
return;
}
Debug.Log($"Profile loaded: {profile.Name}");
Apply(profile);
}
}- Null-guarding logic is scattered everywhere, obscuring the core flow.
- As additional conditions are added,
ifblocks multiply, and missing one step immediately causes exceptions. - Indentation from
ifstatements makes it hard to see at a glance which is the core logic flow and which is null handling.
Option<T> represents values as Some when present or None when absent. When None, subsequent pipeline steps are automatically skipped, naturally organizing null checks.
using UniFP;
public class UserProfileLoader
{
public void Load()
{
var profileOption = Option<string>.From(PlayerPrefs.GetString("profile"))
// 1. If the string is empty, treat it as None
.Filter(DelegateCache.IsNotNullOrWhitespace)
// 2. Parse JSON and elevate the result to Option
.Map(raw => JsonUtility.FromJson<UserProfile>(raw))
.Filter(result => result is { Items: { Length: > 0 } });
profileOption.Match(
onSome: Apply,
onNone: ApplyDefaults);
}
}- Following the pipeline makes it easy to spot which step failed.
- Adding validation logic is as simple as inserting another
Filterstep. - One final
Matchclearly separates normal and fallback flows.
using UniFP;
// 1. Create directly with Some / None
var some = Option<int>.Some(42);
var none = Option<int>.None();
// 2. Convert nullable values to Option with From (null becomes None)
var fromValue = Option<string>.From(PlayerPrefs.GetString("username")); // null becomes None
var fromNullable = Option<int>.From(nullableInt);
// 3. Conditional conversion with Where (None on condition fail)
var adult = Option<int>.From(age)
.Where(x => x >= 18);// Map: Transform value (skip if None)
var doubled = Option<int>.Some(10)
.Map(x => x * 2); // Some(20)
var stillNone = Option<int>.None()
.Map(x => x * 2); // None
// Bind: Chain Option-returning functions
Option<User> FindUser(string name) => /* ... */;
var user = Option<string>.From(username)
.Bind(FindUser);
// Filter: Conditional validation (None on fail)
var valid = Option<int>.From(input)
.Filter(x => x > 0)
.Filter(x => x < 100);
// Or / OrElse: Provide alternative value when None
var withDefault = Option<string>.None()
.Or(Option<string>.Some("default value"));
var fromFunc = Option<int>.None()
.OrElse(() => Option<int>.Some(GetDefaultValue()));
// GetValueOrDefault: Extract value from Option
var value = someOption.GetValueOrDefault(defaultValue);
var valueOrNull = someOption.GetValueOrDefault();// Option -> Result: Convert None to error
var result = Option<User>.From(FindUser(id))
.ToResult(ErrorCode.NotFound, "User not found");
// Result -> Option: Convert failure to None (ignore error)
var option = LoadConfig()
.ToOption(); // Success -> Some, Failure -> None// Match: Different handling based on Some/None
var message = Option<User>.From(user).Match(
onSome: u => $"Welcome, {u.Name}",
onNone: () => "Guest mode");
// IfSome / IfNone: Handle only one case
Option<Config>.From(config)
.IfSome(c => ApplyConfig(c))
.IfNone(() => UseDefaults());using System.Linq;
var items = new[] { 1, 2, 3, 4, 5 };
// TryFirst / TryLast: First/last element as Option
var first = items.TryFirst(); // Some(1)
var firstEven = items.TryFirst(x => x % 2 == 0); // Some(2)
var empty = Array.Empty<int>().TryFirst(); // None
// TryFind: Find element matching condition
var found = items.TryFind(x => x > 3); // Some(4)
// Choose: Extract only Some from Option collection
var options = new[]
{
Option<int>.Some(1),
Option<int>.None(),
Option<int>.Some(3)
};
var values = options.Choose(); // [1, 3]using System.Linq;
// Select: Same as Map
var doubled = Option<int>.Some(10)
.Select(x => x * 2); // Some(20)
// Where: Same as Filter
var filtered = Option<int>.Some(42)
.Where(x => x > 18); // Some(42)
// SelectMany: Same as Bind (LINQ query syntax support)
var result =
from name in Option<string>.From(username)
from user in FindUser(name)
from profile in LoadProfile(user.Id)
select profile;NonEmpty<T> is a collection that guarantees at least one element. Suitable for domains where emptiness is not allowed, such as party composition or required slots.
using UniFP;
// Create: Create with at least one element
var squad = NonEmpty.Create("Leader", "Support", "Tank");
var single = NonEmpty.Create(42);
// FromList: Convert from list (fail if empty)
var list = new List<string> { "A", "B", "C" };
var nonEmpty = NonEmpty.FromList(list); // Result<NonEmpty<string>>
var emptyList = new List<string>();
var failed = NonEmpty.FromList(emptyList); // Failure (empty)// Head / Tail: First element and rest
var squad = NonEmpty.Create("Leader", "Tank", "Healer");
var leader = squad.Head; // "Leader" (always exists)
var others = squad.Tail; // ["Tank", "Healer"] (IEnumerable)
// Map: Transform all elements
var upper = squad.Map(role => role.ToUpper()); // NonEmpty<string>
// Append / Prepend: Add elements
var expanded = squad.Append("Mage"); // NonEmpty (still at least one)
var withNewLeader = squad.Prepend("NewLeader");
// ToList / ToArray: Convert to regular collection
var list = squad.ToList();
var array = squad.ToArray();// Party system: At least one leader required
public class Party
{
private readonly NonEmpty<Player> _members;
public Party(Player leader, params Player[] others)
{
_members = NonEmpty.Create(leader, others);
}
public Player Leader => _members.Head;
public IEnumerable<Player> AllMembers => _members;
public void Buff()
{
// Compile-time guarantee of at least one member
_members.Map(p => p.ApplyBuff());
}
}
// Configuration: At least one server address required
var servers = NonEmpty.Create(
"https://primary.server.com",
"https://backup1.server.com",
"https://backup2.server.com"
);
var primary = servers.Head;
var fallbacks = servers.Tail;UniFP provides Zero-GC error classification with the ErrorCode struct.
// 0-999: UniFP reserved range
ErrorCode.None // 0: No error
ErrorCode.Unknown // 1: Unknown error
ErrorCode.InvalidInput // 100: Invalid input
ErrorCode.ValidationFailed // 101: Validation failed
ErrorCode.NotFound // 102: Not found
ErrorCode.Unauthorized // 103: Unauthorized
ErrorCode.OperationFailed // 104: Operation failed
ErrorCode.Timeout // 105: Timeout
ErrorCode.NetworkError // 106: Network error
ErrorCode.Forbidden // 107: Forbidden
ErrorCode.InvalidOperation // 108: Invalid operation// 1000+: User-defined error codes
public static class GameErrors
{
public static readonly ErrorCode InsufficientGold =
ErrorCode.Custom(1001, "Economy");
public static readonly ErrorCode InventoryFull =
ErrorCode.Custom(1002, "Inventory");
public static readonly ErrorCode QuestNotAvailable =
ErrorCode.Custom(1003, "Quest");
}
// Usage example
var result = PurchaseItem(itemId, price)
.Filter(success => player.Gold >= price, GameErrors.InsufficientGold,
$"Insufficient gold: need {price - player.Gold} more");var error = ErrorCode.NotFound;
error.Code; // 102
error.Category; // "Resource"
error.IsCustom; // false (built-in code)
var custom = ErrorCode.Custom(2001, "Payment");
custom.Code; // 2001
custom.Category; // "Payment"
custom.IsCustom; // true// Automatically recorded in Editor or UNIFP_DEBUG environment
var result = LoadAsset(path)
.Filter(asset => asset != null, ErrorCode.NotFound);
if (result.IsFailure)
{
// Information automatically recorded on failure
Debug.LogError($"[{result.ErrorCode.Category}] {result.Error}");
Debug.LogError($"Location: {result.FilePath}:{result.LineNumber}");
Debug.LogError($"Method: {result.MemberName}");
Debug.LogError($"Operation type: {result.OperationType}");
// Example output:
// [Resource] Asset not found: player_model.prefab
// Location: Assets/Scripts/AssetLoader.cs:42
// Method: LoadPlayerModel
// Operation type: Filter
}Import the UniFP namespace to utilize all extension methods. The railway pattern cleanly separates success and failure paths.
var pipeline = Result.FromValue(request)
.Filter(req => req.IsValid, ErrorCode.ValidationFailed)
.Then(Persist)
.DoStrict(SendAnalyticsEvent)
.IfFailed(() => LoadCachedResult())
.Trace("Purchase");Recover(Func<ErrorCode, T>)replaces failures with default values.IfFailed(Func<Result<T>>)executes an alternative pipeline.ThenIfandMapIfconditionally perform additional work.DoStrictis suitable for side effects that need to propagate failures (e.g., analytics events, database logging).
ResultCombinators can bundle independent operations into a single result.
var stats = ResultCombinators.Combine(
LoadLevelProgress(),
LoadInventory());
var snapshot = stats.Zip(
CalculateRewards(),
(progress, inventory, rewards) => new PlayerSnapshot(progress, inventory, rewards));SelectResultstraverses collections and halts immediately on failure.CombineAllgathers multipleResult<T>intoResult<List<T>>.FilterResults,Partition,Fold,AggregateResults, etc. perform list validation and aggregation.SpanExtensionsoperates onSpan<T>without additional allocation, even in Burst-sensitive code.
UniFP supports async operations with both UniTask (recommended) and Unity Awaitable (Unity 6.0+).
With UniTask installed:
using Cysharp.Threading.Tasks;
async UniTask<Result<PlayerData>> FetchPlayer(int id)
{
return await Result.TryFromResult(() => ValidateId(id))
.ThenAsync(async _ => await Api.GetPlayer(id))
.MapAsync(payload => payload.ToPlayerData())
.FilterAsync(data => UniTask.FromResult(data.IsActive), "Player is not active");
}With Unity 6.0+ (Awaitable):
using UnityEngine;
async Awaitable<Result<PlayerData>> FetchPlayer(int id)
{
return await Result.TryFromResult(() => ValidateId(id))
.ThenAsync(async _ => await Api.GetPlayer(id))
.MapAsync(payload => payload.ToPlayerData())
.FilterAsync(data => Awaitable.FromResult(data.IsActive), "Player is not active");
}Both provide the same API - just swap the async type!
var cached = await FetchPlayer(42).DoAsync(data => Cache.Save(data));
Async operations that throw exceptions can be wrapped with `AsyncResult.TryAsync` to automatically convert them to `Result` failures.
## Resilience Utilities
- `Retry` and `RetryAsync` perform retries up to the specified number of attempts.
- `RetryWithBackoff` applies exponential backoff delays for handling unstable services.
- `Repeat` and `RepeatAsync` handle scenarios requiring N consecutive successes.
- `Catch` intercepts specific failure messages and executes alternative logic.
```csharp
var response = await RetryExtensions.RetryWithBackoff(
() => Api.SendAsync(payload),
maxAttempts: 5,
initialDelayMilliseconds: 200,
backoffMultiplier: 2.5f);
SafeExecutorrecords the location and type of each operation.PipelineDebug.Trace,TraceWith,TraceOnFailure, andBreakpointtrack pipeline state in the console.- The
OperationTypeenum immediately shows which step (Map,Filter,Then, etc.) failed.
var result = LoadConfig()
.Trace("Config")
.Assert(cfg => cfg.Version >= 2, "Config version too old")
.Breakpoint();While basic usage has no major issues, if you're working with logic that runs every frame (like in Update methods), the following optimizations are necessary:
- DelegateCache: Reuses frequently-used lambdas with a static cache.
- ResultPool & ListPool: Pools result collections to eliminate GC in high-frequency loops.
- SpanExtensions: Provides stack or pooled buffer-based transformations.
- Zero-allocation error flow:
ErrorCode,OperationType, and struct monads suppress heap usage.
These utilities run in sample scenes like inventory handling, combat resolution, and shop purchases, operating stably without GC allocation.
Assets/Scenes01_BasicResultExample— Result fundamentals02_PipelineExample— Chaining patterns04_AsyncExample— UniTask integration and async flow06_PerformanceExample— Zero-allocation techniques10_RealWorld_UserLogin— Robust login pipeline11_RealWorld_ItemPurchase— Railway handling across services
- Tests are located in
src/UniFP/Assets/Tests, covering validation, async failures, retry scenarios, and other major edge cases.
To run tests from the repository root, use the following command:
dotnet test src/UniFP/UniFP.Tests.csprojExtended guides are available in the docs folder:
Issues and pull requests are always welcome. Please write tests and examples before submitting changes.
UniFP is distributed under the MIT License.