A lightweight, zero-allocation functional extensions library for Unity, inspired by CSharpFunctionalExtensions.
Focuses on explicitly handling success/failure without throwing exceptions and optional values without null, using Result<T,E>
and Maybe<T>
types.
- Supports both synchronous and asynchronous workflows:
- UniTask integration (if
Cysharp.Threading.Tasks
is installed andNOPE_UNITASK
define is set). - Awaitable integration (if on Unity6+ with built-in
Awaitable
, usingNOPE_AWAITABLE
define).
- UniTask integration (if
- Full sync ↔ async bridging for both
Result<T,E>
andMaybe<T>
:
Map/Bind/Tap/Match/Finally now have "all combos" (sync→async, async→sync, async→async). - Minimal GC pressure: implemented as
readonly struct
to keep allocations low.
Define Symbol Usage:
- In your Project Settings, defineNOPE_UNITASK
if you want UniTask-based async.
- Or defineNOPE_AWAITABLE
(Unity6+) if you want the built-in Awaitable integrations.
- If you only plan to use synchronous methods, you can omit both defines.
- Motivation & Identity
- Performance Comparison
- Installation
- Quick “Before & After”
- Features Overview
- Result<T,E> Usage
- Maybe<T> Usage
- Async Support
- Usage Examples
- API Reference
- License
NOPE aims to eliminate silent null
checks and hidden exceptions from your code. Instead, we use:
- Result<T,E> for explicit success/failure.
- Maybe<T> for optional values, similar to “nullable but without null pointer.”
Thus you can chain safe transformations (Map
, Bind
, Tap
), or handle outcomes (Match
, Finally
) in a clean, functional style.
Goal: Make complex code more readable, safer, and explicit about error handling.
Philosophy: No hidden exceptions or null
surprises. Return “Failure” or “None” states explicitly, with or without user-defined error types.
The following performance measurements were taken in an environment where the NOPE library's features were used comprehensively. The tests include comparisons with CSharpFunctionalExtensions
, Optional
, LanguageExt
, and OneOf
libraries.
Please note that not all libraries support exactly the same features. In some cases, similar functions that produce equivalent results from the user's perspective were used for comparison.
-
Via Git (UPM):
InPackages/manifest.json
:{ "dependencies": { "com.kwanjoong.nope": "https://github.com/kwan3854/Unity-NOPE.git?path=/Packages/Unity-NOPE" } }
To specify a version, use:
{ "dependencies": { "com.kwanjoong.nope": "https://github.com/kwan3854/Unity-NOPE.git?path=/Packages/Unity-NOPE#1.2.0" } }
-
Unity Package Manager (Git):
Window → Package Manager
- “+” → “Add package from git URL…”
- Paste
https://github.com/kwan3854/Unity-NOPE.git?path=/Packages/Unity-NOPE
to specify a version, append version tag likehttps://github.com/kwan3854/Unity-NOPE.git?path=/Packages/Unity-NOPE#1.2.0
.
-
OpenUPM:
On CLI,openupm add com.kwanjoong.nope
. -
Manual Download:
Clone or download, then place inPackages/
orAssets/Plugins
.
Note
Defines:
NOPE_UNITASK
for using UniTask integrationNOPE_AWAITABLE
for Unity6+ built-in Awaitable integration- Omit both if you only want synchronous usage.
- Don't define both at the same time.
Imagine a function that checks two or three conditions, fetches some data asynchronously, ensures the data is valid, and returns either a success result or logs some error.
public async Task<string> DoStuff()
{
// a) check some condition
if (!CheckA())
throw new Exception("Condition A failed!");
// b) fetch data
var data = await FetchData(); // might return null?
if (data == null)
return null; // ?
// c) parse & validate
var parsed = Parse(data);
if (parsed <= 0)
return "Negative value?";
// d) do final step
if (!await FinalStep(parsed))
return "Final step failed!";
return "All Good!";
}
Problems: We have a mix of thrown exceptions, null
, special strings. It’s easy to forget checks or accidentally skip error paths.
public async UniTask<Result<string, string>> DoStuff()
{
return await Result.SuccessIf(CheckA(), Unit.Value, "Condition A failed!")
.Bind(_ => FetchData()
.Map(data => Parse(data))
.Ensure(x => x > 0, "Parsed <= 0?"))
.Bind(parsed => FinalStep(parsed)
.Map(success => success
? "All Good!"
: "Final step failed!"));
}
Here, each step returns a Result<T>
, we do Bind/Map/Ensure to unify success/failure in one chain. No null
or thrown exceptions.
-
Result<T,E>
- chainable methods:
Map
,Bind
,Tap
,Ensure
,MapError
,Match
,Finally
- combine multiple results with
Combine
(no value) orCombineValues
(with new tuple/array)
- chainable methods:
-
Maybe
- “optional” type, no
null
needed Map
,Bind
,Tap
,Match
,Where
,Execute
and more- LINQ integration (
Select
,SelectMany
,Where
)
- “optional” type, no
-
Sync ↔ Async bridging
- For every method (
Bind
,Map
, etc.), we have:- sync→sync, sync→async, async→sync, async→async
- Works with UniTask (if
NOPE_UNITASK
) or Awaitable (ifNOPE_AWAITABLE
) - So you can seamlessly mix synchronous and asynchronous steps in a single chain.
- For every method (
-
Collection Utilities
- For
Maybe<T>
:TryFind
,TryFirst
,TryLast
,Choose
, etc.
- For
// Basic success/failure
var r1 = Result<int, string>.Success(100);
var r2 = Result<int, string>.Failure("Oops");
// Implicit conversion
Result<int, string> r3 = 10;
Assert.IsTrue(r3.IsSuccess);
Assert.AreEqual(10, r3.Value);
Result<int, string> r4 = "Error";
Assert.IsTrue(r4.IsFailure);
Assert.AreEqual("Error", r4.Error);
var a = 100;
var b = 200;
Result<int, string> r5 = b == 0 ?
"Divide by zero"
: 100;
Assert.IsTrue(r5.IsSuccess);
Assert.AreEqual(100, r5.Value);
// If you use a custom error type E:
var r6 = Result<int, SomeErrorEnum>.Failure(SomeErrorEnum.FileNotFound);
-
Combine
- Gathers multiple
Result<T,E>
into a single “valueless”Result<Unit, E>
(only success/failure). - If all are success → returns Success(). If any fail → returns the first error.
var r1 = Result<int, string>.Success(2); var r2 = Result<int, string>.Success(3); var combined = Result.Combine(r1, r2); Assert.IsTrue(combined.IsSuccess); Assert.AreEqual(Unit.Value, combined.Value); var r3 = Result<int, string>.Failure("Fail"); var combined2 = Result.Combine(r1, r3); Assert.IsTrue(combined2.IsFailure); Assert.AreEqual("Fail", combined2.Error);
- Gathers multiple
-
CombineValues
- Gathers multiple
Result<T,E>
into a singleResult<(T1,T2,...) , E>
orResult<T[], E>
. - If any fail, returns that error. Otherwise, returns a new combined “value.”
var r1 = Result<int, string>.Success(2); var r2 = Result<int, string>.Success(3); var r3 = Result<int, string>.Failure("Fail"); // Combine two results into a tuple var combinedTuple = Result.CombineValues(r1, r2); Assert.IsTrue(combinedTuple.IsSuccess); Assert.AreEqual((2, 3), combinedTuple.Value); // Combine three results into an array var combinedArray = Result.CombineValues(r1, r2, r3); Assert.IsTrue(combinedArray.IsFailure); Assert.AreEqual("Fail", combinedArray.Error)
- Gathers multiple
SuccessIf(condition, successValue, error)
→ “if condition is true → success, else → fail.”FailureIf(condition, successValue, error)
→ “if condition is true → fail, else → success.”Of(func, errorConverter)
→ Wrap a try/catch block, returning success if no exception, else fail(error).
var x = 10;
var r1 = Result.SuccessIf(() => x > 5, x, "TooSmall");
Assert.IsTrue(r1.IsSuccess);
var r2 = Result.FailureIf(() => x % 2 == 0, 999, "CondFailed");
Assert.IsTrue(r2.IsFailure);
Assert.AreEqual("CondFailed", r2.Error);
var r3 = Result.Of(() => x / 0, ex => $"{ex.Message} Added info");
Assert.IsTrue(r3.IsFailure);
Assert.AreEqual("Attempted to divide by zero. Added info", r3.Error);
- Bind: transform
Result<TOriginal,E>
→Result<TNew,E>
if success, else pass through error.var r1 = Result<int, string>.Success(10); var r2 = r1.Bind(x => Result<string, string>.Success($"Value is {x}")); Assert.IsTrue(r2.IsSuccess); Assert.AreEqual("Value is 10", r2.Value); var r3 = Result<int, string>.Failure("Initial failure"); var r4 = r3.Bind(x => Result<string, string>.Success($"Value is {x}")); Assert.IsTrue(r4.IsFailure); Assert.AreEqual("Initial failure", r4.Error);
- Map: transform the value if success →
Result<U,E>
, no extra error.var r1 = Result<int, string>.Success(10); var r2 = r1.Map(x => x + 1); Assert.IsTrue(r2.IsSuccess); Assert.AreEqual(11, r2.Value); var r3 = Result<int, string>.Failure("Initial failure"); var r4 = r3.Map(x => x + 1); Assert.IsTrue(r4.IsFailure); Assert.AreEqual("Initial failure", r4.Error);
Tip
Simple transformation on success (T → U)
// mapFunc: int => string
string mapFunc(int x) => $"Value is {x}";
var r1 = Result<int, string>.Success(10);
var r2 = r1.Map(mapFunc);
// r2 : Result<string, string>
// Success => "Value is 10"
Since mapFunc
itself returns a string, Map
internally creates Result<string, E>.Success(mapFunc(x))
. If mapFunc
needs to produce an exception or failure, it is not possible (you would have to throw directly, which is outside the Result pattern).
Another Result on success (T → Result<U,E>)
// bindFunc: int => Result<string,string>
Result<string,string> bindFunc(int x)
{
if (x > 5)
return Result<string,string>.Success($"Value is {x}");
else
return Result<string,string>.Failure("x <= 5");
}
var r3 = Result<int,string>.Success(10);
var r4 = r3.Bind(bindFunc);
// r4 : Result<string,string>
// Success => "Value is 10"
bindFunc
contains logic to directly produce "success or failure". Bind
works by "calling bindFunc
and returning its result (success or failure) if the input is successful", "keeping the existing failure if the input is a failure".
- MapError: only changes the error.
var r1 = Result<int, string>.Failure("Initial error"); var r2 = r1.MapError(e => $"Custom: {e}"); Assert.IsTrue(r2.IsFailure); Assert.AreEqual("Custom: Initial error", r2.Error); var r3 = Result<int, string>.Success(10); var r4 = r3.MapError(e => $"Custom: {e}"); Assert.IsTrue(r4.IsSuccess); Assert.AreEqual(10, r4.Value);
- Tap: run side effect if success.
var r1 = Result<int, string>.Success(10); var r2 = r1.Tap(x => Debug.Log($"Value = {x}")); Assert.IsTrue(r2.IsSuccess); Assert.AreEqual(10, r2.Value); var r3 = Result<int, string>.Failure("Initial failure"); var r4 = r3.Tap(x => Debug.Log($"Value = {x}")); Assert.IsTrue(r4.IsFailure); Assert.AreEqual("Initial failure", r4.Error);
- Ensure: “if success but fails predicate => become fail(error).”
var r1 = Result<int, string>.Success(15); var r2 = r1.Ensure(x => x > 10, "too small?"); Assert.IsTrue(r2.IsSuccess); Assert.AreEqual(15, r2.Value); var r3 = Result<int, string>.Success(5); var r4 = r3.Ensure(x => x > 10, "too small?"); Assert.IsTrue(r4.IsFailure); Assert.AreEqual("too small?", r4.Error);
- Match: convert a
Result<T,E>
into a single outcome:var r1 = Result<int, string>.Success(10); var result1 = r1.Match( onSuccess: val => $"Value = {val}", onFailure: err => $"Err = {err}" ); Assert.AreEqual("Value = 10", result1); var r2 = Result<int, string>.Failure("Initial failure"); var result2 = r2.Match( onSuccess: val => $"Value = {val}", onFailure: err => $"Err = {err}" ); Assert.AreEqual("Err = Initial failure", result2);
- Finally: “chain termination” with a final function.
var r1 = Result<int, string>.Success(10); var finalString1 = r1.Finally(res => { // do side effects return res.IsSuccess ? "OK" : $"Fail({res.Error})"; }); Assert.AreEqual("OK", finalString1); var r2 = Result<int, string>.Failure("Initial failure"); var finalString2 = r2.Finally(res => { // do side effects return res.IsSuccess ? "OK" : $"Fail({res.Error})"; }); Assert.AreEqual("Fail(Initial failure)", finalString2);
All these methods have sync → async or async → async variants if
NOPE_UNITASK
/NOPE_AWAITABLE
is set.
Maybe<T>
represents an optional value (like a Nullable<T>
but without boxing and no null checks).
Maybe<int> m1 = 100; // => HasValue=true
Maybe<int> m2 = Maybe<int>.None; // => no value
// Basic creation
Maybe<int> m1 = 100; // => HasValue=true
Maybe<int> m2 = Maybe<int>.None; // => no value
// From a nullable type
int? nullableInt = 10;
Maybe<int?> m3 = Maybe<int?>.From(nullableInt); // => HasValue=true
Assert.IsTrue(m3.HasValue);
nullableInt = null;
Maybe<int?> m4 = Maybe<int?>.From(nullableInt); // => no value
Assert.IsFalse(m4.HasValue);
-
Map: transform the value if it exists.
Maybe<int> m1 = 10; Maybe<string> m2 = m1.Map(x => $"Value is {x}"); Assert.IsTrue(m2.HasValue); Assert.AreEqual("Value is 10", m2.Value); Maybe<int> m3 = Maybe<int>.None; Maybe<string> m4 = m3.Map(x => $"Value is {x}"); Assert.IsFalse(m4.HasValue);
-
Bind: transform the value into another
Maybe<T>
.Maybe<int> m1 = 10; Maybe<string> m2 = m1.Bind(x => Maybe<string>.From($"Value is {x}")); Assert.IsTrue(m2.HasValue); Assert.AreEqual("Value is 10", m2.Value); Maybe<int> m3 = Maybe<int>.None; Maybe<string> m4 = m3.Bind(x => Maybe<string>.From($"Value is {x}")); Assert.IsFalse(m4.HasValue);
-
Tap: run a side effect if the value exists.
Maybe<int> m1 = 10; m1.Tap(x => Console.WriteLine($"Value = {x}")); Maybe<int> m2 = Maybe<int>.None; m2.Tap(x => Console.WriteLine($"Value = {x}")); // No output
-
Match: convert a
Maybe<T>
into a single outcome.Maybe<int> m1 = 10; string result1 = m1.Match( onValue: val => $"Value = {val}", onNone: () => "No value" ); Assert.AreEqual("Value = 10", result1); Maybe<int> m2 = Maybe<int>.None; string result2 = m2.Match( onValue: val => $"Value = {val}", onNone: () => "No value" ); Assert.AreEqual("No value", result2);
-
Where: if
HasValue
but doesn’t satisfy the predicate, becomes None.Maybe<int> m1 = 10; Maybe<int> m2 = m1.Where(x => x > 5); Assert.IsTrue(m2.HasValue); Maybe<int> m3 = 3; Maybe<int> m4 = m3.Where(x => x > 5); Assert.IsFalse(m4.HasValue);
-
Execute: run an action if the Maybe has a value.
Maybe<int> m1 = 10; m1.Execute(val => Console.WriteLine($"This will print: {val}")); Assert.AreEqual(10, m1.Value); Maybe<int> m2 = Maybe<int>.None; m2.Execute(val => Console.WriteLine($"This will not print: {val}")); Assert.IsFalse(m2.HasValue);
-
Or: provide a fallback value if None.
Maybe<int> m1 = 10; Maybe<int> maybeValue1 = m1.Or(0); Assert.AreEqual(10, maybeValue1.Value); Maybe<int> m2 = Maybe<int>.None; var maybeValue2 = m2.Or(0); Assert.AreEqual(0, maybeValue2.Value);
-
GetValueOrThrow, GetValueOrDefault: for direct extraction.
Maybe<int> m1 = 10; int value1 = m1.GetValueOrThrow(); Assert.AreEqual(10, value1); Maybe<int> m2 = Maybe<int>.None; int value2 = m2.GetValueOrDefault(0); Assert.AreEqual(0, value2);
We provide collection helpers returning a Maybe<T>
:
-
dict.TryFind(key) -> Maybe<TValue>
Dictionary<string, int> dict = new() { { "apple", 10 }, { "banana", 5 } }; Maybe<int> found = dict.TryFind("banana"); Assert.IsTrue(found.HasValue); Assert.AreEqual(5, found.Value); Maybe<int> notFound = dict.TryFind("cherry"); Assert.IsFalse(notFound.HasValue);
-
source.TryFirst()
,source.TryLast()
→ MaybeList<int> list = new() { 1, 2, 3 }; Maybe<int> first = list.TryFirst(); Assert.IsTrue(first.HasValue); Assert.AreEqual(1, first.Value); Maybe<int> last = list.TryLast(); Assert.IsTrue(last.HasValue); Assert.AreEqual(3, last.Value); List<int> emptyList = new(); Maybe<int> none = emptyList.TryFirst(); Assert.IsFalse(none.HasValue);
-
Choose(...)
to filter out None from a sequence ofMaybe<T>
.List<Maybe<int>> list = new() { Maybe<int>.From(1), Maybe<int>.None, Maybe<int>.From(3) }; List<int> chosen = list.Choose().ToList(); Assert.AreEqual(2, chosen.Count); Assert.AreEqual(1, chosen[0]); Assert.AreEqual(3, chosen[1]);
We have Select
, SelectMany
, Where
so you can do:
Maybe<int> maybeNum = 50;
var query =
from x in maybeNum
where x > 10
select x * 2;
// => Maybe(100)
This detailed explanation should now be on par with the Result<T,E>
section.
If you define NOPE_UNITASK
, we add UniTask<Result<T,E>>
/ UniTask<Maybe<T>>
overloads for Map/Bind/etc.
If you define NOPE_AWAITABLE
(Unity6+), we add Awaitable<Result<T,E>>
/ Awaitable<Maybe<T>>
overloads.
// syncResult + asyncBinder
public static async UniTask<Result<TNew>> Bind<T,TNew>(
this Result<T> result,
Func<T, UniTask<Result<TNew>>> asyncBinder);
public static async Awaitable<Result<TNew>> Bind<T,TNew>(
this Result<T> result,
Func<T, Awaitable<Result<TNew>>> asyncBinder);
So you can seamlessly chain a synchronous step into an async step. Similarly, we have asyncResult + sync transform overloads.
-
Chaining multiple checks & async calls with
Result<int>
:public async UniTask<Result<string, string>> ComplexOperation() { return Result.SuccessIf(CheckA(), 0, "CheckA failed!") .Bind(_ => FetchDataAsync()) // => UniTask<Result<string>> .Ensure(str => !string.IsNullOrEmpty(str), "Empty data!") .Map(str => str.Length) .Bind(len => FinalStepAsync(len)) .Match( onSuccess: val => $"Final OK: {val}", onFailure: err => $"Failure: {err}" ); }
-
Maybe usage with dictionary:
Dictionary<string,int> dict = new() { {"apple", 10}, {"banana", 5} }; var found = dict.TryFind("banana") .Where(x => x >= 5) .Map(x => x*2) // => Maybe(10) .Execute(value => Debug.Log("HasValue: " + value)) .ExecuteNoValue(() => Debug.LogWarning("Not found or zero")); // found => Maybe(10)
-
Combine / CombineValues:
var r1 = Result<int, string>.Success(2); var r2 = Result<int, string>.Success(3); var merged = Result.CombineValues(r1, r2); // => Result<(int,int)>.Success((2,3)) var justCheck = Result.Combine(r1, r2); // => Result.Success() or first error
-
LINQ with Maybe:
Maybe<int> maybeNum = 10; var query = from x in maybeNum where x > 5 select x*3; // => Maybe(30)
Result<T,E>
- Combine / CombineValues
- SuccessIf, FailureIf, Of
- Bind, Map, MapError, Tap, Ensure, Match, Finally
- BindSafe, MapSafe, TapSafe
- Overloads for sync→async bridging.
Maybe<T>
- Map, Bind, Tap, Match, Finally
- Where, Execute, Or, GetValueOrThrow, etc.
- TryFind, TryFirst, TryLast, Choose from collections.
- LINQ operators: Select, SelectMany, Where.
For the complete list, see the
.cs
files inNOPE.Runtime.Core.Result
/NOPE.Runtime.Core.Maybe
.
MIT License.
Contributions & Pull requests are welcome.