RZ.Foundation
8.3.0
dotnet add package RZ.Foundation --version 8.3.0
NuGet\Install-Package RZ.Foundation -Version 8.3.0
<PackageReference Include="RZ.Foundation" Version="8.3.0" />
<PackageVersion Include="RZ.Foundation" Version="8.3.0" />
<PackageReference Include="RZ.Foundation" />
paket add RZ.Foundation --version 8.3.0
#r "nuget: RZ.Foundation, 8.3.0"
#:package RZ.Foundation@8.3.0
#addin nuget:?package=RZ.Foundation&version=8.3.0
#tool nuget:?package=RZ.Foundation&version=8.3.0
RZ.Foundation
RZ.Foundation is a functional add-on to LanguageExt.
Its centrepiece is Outcome<T>: a result type that brings Go's "errors are values"
philosophy to C#. Instead of throwing exceptions and hoping someone, somewhere, remembers to
catch, a function simply returns either its value or a structured ErrorInfo — and
the compiler keeps you honest about which one you got.
// The signature tells the whole story: this can fail, and you must deal with it.
Outcome<User> FindUser(string id);
Why Outcome<T>?
In the exception model, the failure path is invisible. A method that returns User looks
total, but may blow up the call stack at runtime. You only discover the failure modes by
reading the implementation — or in production.
Outcome<T> makes failure a first-class, visible part of the return value:
- Explicit — failure is in the type signature, not hidden in a comment.
- Local — you handle (or deliberately forward) the error right where it happens.
- Cheap — no stack unwinding on the expected-error path; exceptions are reserved for the genuinely exceptional and for system boundaries.
Go vs. C#, side by side
// Go
v, err := DoThing()
if err != nil {
return err // forward the error upward
}
use(v)
// C# with RZ.Foundation
if (Fail(DoThing(), out var e, out var v))
return e.Trace(); // forward the error upward (and record this hop — see below)
Use(v);
Same shape, same intent. The C# side gets something extra for free: calling e.Trace()
stamps the current file/method/line onto the error as it travels up, so by the time it
surfaces you have a readable, application-level breadcrumb trail.
All examples below assume the project's global usings are in effect:
using static RZ.Foundation.AOT.Prelude;andusing static RZ.Foundation.StandardErrorCodes;.Outcome<T>lives inRZ.Foundation;ErrorInfoinRZ.Foundation.Types.
Outcome<T> in 60 seconds
An Outcome<T> is either a success carrying a T or a failure carrying an
ErrorInfo. You rarely construct it explicitly — values and errors implicitly convert:
Outcome<int> ok = 42; // success, by implicit conversion
Outcome<int> bad = new ErrorInfo(NotFound, "no such row"); // failure, by implicit conversion
Outcome<int> bad2 = ErrorInfo.New(NotFound, "no such row"); // failure, by a helper function with location attached
// Explicit factories when type inference needs a nudge:
var a = SuccessOutcome(42); // Outcome<int> success
var b = FailedOutcome<int>(ErrorInfo.NotFound);
var u = UnitOutcome; // Outcome<Unit>, the "succeeded, nothing to return" case
A function returning Outcome<T> therefore reads naturally — just return value; or
return error;:
Outcome<int> Divide(int a, int b)
=> b == 0 ? new ErrorInfo(InvalidRequest, "division by zero") : a / b;
ErrorInfo — the structured replacement for Exception
ErrorInfo is what Outcome<T> carries on failure. It is an immutable record designed to
describe an error well enough that you never need an exception type per failure mode.
| Field | Purpose |
|---|---|
Code |
Machine-readable category, e.g. "not-found" |
Message |
Human-readable description |
TraceId |
Distributed-trace id, auto-filled from Activity.Current |
DebugInfo |
Extra developer detail (omit in release responses) |
Data |
Serialized payload associated with the error |
Locations |
The application-side call trail accumulated by Trace() |
Use the well-known codes from StandardErrorCodes (NotFound, InvalidRequest, Timeout,
Duplication, HttpError, Unhandled, ValidationFailed, …) rather than inventing strings:
var notFound = ErrorInfo.New(NotFound, "user 42 does not exist");
var invalid = new ErrorInfo(InvalidRequest, "email is required");
// Inspect:
notFound.Is(NotFound); // true
notFound.IsNotFound(); // true — shorthand for the common case
// Re-tag a low-level error with higher-level context:
var wrapped = dbError.Wrap(ServiceError, "failed to load user profile");
Synchronous flow
The everyday pattern is the guard: pull the value out, or short-circuit. Two mirror-image helpers cover it.
Success(...) is true on the happy path:
if (Success(FindUser(id), out var user, out var error))
Render(user);
else
Log(error);
Fail(...) is true on the failure path — perfect for early-return propagation:
Outcome<Invoice> BuildInvoice(string userId, string itemId) {
if (Fail(FindUser(userId), out var e, out var user)) return e.Trace();
if (Fail(FindItem(itemId), out e, out var item)) return e.Trace();
return new Invoice(user, item); // implicit conversion to Outcome<Invoice>
}
Always forward with
return e.Trace();, not barereturn e;.Trace()appends the current file/method/line to the error'sLocations. As the error bubbles throughBuildInvoice→ its caller → its caller, each hop leaves a marker, giving you an application-level trace that reads in your own terms — far more useful than a raw CLR stack trace, and it costs nothing on the success path.
There are convenient overloads. Take only what you need:
if (Fail(result, out var e)) return e.Trace(); // don't care about the value
if (Success(result, out var v)) Use(v); // don't care about the error
Tuple results deconstruct in one step:
if (Fail(LoadPair(), out var e, out var first, out var second)) return e.Trace();
// first and second are in scope here
To collapse an Outcome<T> into a plain value, use Match or IfFail:
var label = FindUser(id).Match(u => u.Name, _ => "(unknown)");
var count = CountRows().IfFail(0); // default on failure
var count2 = CountRows().IfFail(e => -1); // compute a default from the error
Asynchronous flow
Async code returns ValueTask<Outcome<T>>. The pattern is identical — await first, then
guard exactly as in the synchronous case:
async ValueTask<Outcome<Profile>> LoadProfile(string id) {
if (Fail(await FetchUser(id), out var e, out var user)) return e.Trace();
if (Fail(await FetchAvatar(user), out e, out var avatar)) return e.Trace();
return new Profile(user, avatar);
}
When the work might throw (an HTTP call, a DB driver), wrap it with TryCatch to turn an
exception into a failed Outcome instead of letting it escape (see
Bridging with exceptions for the full set):
ValueTask<Outcome<string>> GetBody(Uri url) =>
TryCatch(async () => await httpClient.GetStringAsync(url));
// a thrown HttpRequestException becomes a failed Outcome<string>
The NotFound convention
There is one error code the library treats specially: NotFound. It exists for a common
situation — a lookup that may legitimately find nothing, where "nothing" is an ordinary
outcome rather than a real failure.
You could model that with Outcome<Option<T>>: success-with-Some, success-with-None, or
failure. It is precise, but every caller now has to unwrap two layers. The lighter alternative
is to return a plain Outcome<T> and signal absence with a NotFound failure:
// Returns the user, or a NotFound failure if there is no such id.
Outcome<User> FindUser(string id)
=> TryLookup(id) is { } user ? user : ErrorInfo.NotFound;
Callers that don't care about the distinction just forward like any other error. Callers that do care get purpose-built helpers so "missing" never gets mistaken for "broken":
// Forward real errors, but let "not found" fall through to a fallback.
if (FailButNotFound(FindUser(id), out var e, out var user)) return e.Trace();
var effective = user ?? User.Guest; // not-found ⇒ user is null here
// Or substitute a fallback inline — only not-found is replaced; real errors pass through:
Outcome<User> u = FindUser(id).IfNotFound(User.Guest);
Outcome<User> u2 = FindUser(id).IfNotFound(() => LoadDefault());
// Test it directly:
if (FindUser(id).IsNotFound()) ...
// When a caller genuinely wants the Option<T> shape back, recover it:
Outcome<Option<User>> maybe = FindUser(id).CheckNotFound(); // not-found ⇒ success(None)
// At an exception boundary, not-found becomes null instead of throwing:
User? user = await ThrowUnlessNotFound(FindUserAsync(id));
Pitfall — document the behaviour. Returning
NotFoundfrom anOutcome<T>is exactly likeList.FindIndexreturning-1: a useful shortcut that is invisible from the signature. If a function usesNotFoundas a normal, expected result, say so in its doc comment — otherwise a caller will treat the absence as an error and propagate it. Reach forOutcome<Option<T>>instead when the distinction must be impossible to miss.
Streaming with IAsyncEnumerable<Outcome<T>>
A risky async stream can be turned into a stream of outcomes with TryCatch, which wraps each
item and emits a trailing failure item if iteration throws:
IAsyncEnumerable<Outcome<Row>> rows = TryCatch(ReadRowsAsync(query));
To consume the whole stream into a list, use MakeList (read-only) or MakeMutableList
(a List<T>). Both short-circuit on the first failure — you get the value list, or the
first error, never a half-built list paired with a swallowed exception:
// Outcome<IReadOnlyList<Row>> — all rows, or the first error encountered
if (Fail(await rows.MakeList(), out var e, out var allRows)) return e.Trace();
Process(allRows);
// MakeMutableList when you need to keep appending afterwards:
if (Fail(await TryCatch(ReadRowsAsync(query)).MakeMutableList(), out var e2, out var list))
return e2.Trace();
list.Add(extraRow);
Both have a selector overload that projects each item while collecting:
// Outcome<IReadOnlyList<string>>
var names = await TryCatch(ReadUsersAsync()).MakeList(u => u.Name);
Note: if the source is known to be empty,
MakeList/MakeMutableListreturnErrorInfo.NotFoundrather than an empty list — handle it withFailButNotFoundorIfNotFoundif "no rows" is acceptable for your case.
The same extension set also offers First, Last, Average, and AverageBy, each returning
an Outcome<T> that short-circuits on the first failing item.
Bridging with exceptions
Outcome<T> interoperates cleanly with exception-based code at both ends.
Pulling exceptions in. TryCatch (sync and async) converts a throwing call into an
Outcome; Try returns a plain (Exception?, T) tuple you can convert later:
Outcome<int> parsed = TryCatch(() => int.Parse(input)); // sync
Pushing errors out. At an API boundary where callers expect exceptions, unwrap:
var user = FindUser(id).Unwrap(); // throws ErrorInfoException if failed
var user2 = await ThrowIfError(LoadProfile(id)); // same, for ValueTask<Outcome<T>>
var maybe = await ThrowUnlessNotFound(Load(id)); // throws on real errors; null on not-found
Custom exceptions → codes. Tag an exception type with [ErrorInfo] and ErrorFrom
will map it to the right Code automatically; otherwise it becomes Unhandled:
[ErrorInfo(Duplication)]
sealed class DuplicateKeyException(string msg) : Exception(msg);
// Anywhere a caught exception needs to become an ErrorInfo:
ErrorInfo info = ErrorFrom.Exception(caughtException); // honours [ErrorInfo]
ErrorInfo prog = ErrorFrom.Program("invalid state"); // quick InvalidRequest with caller name
ErrorInfoException is the bridge type: it carries Code and DebugInfo, and round-trips
via .ToErrorInfo().
Composing outcomes (Experimental)
Outcome<T> supports the usual functional combinators:
var len = FindUser(id).Map(u => u.Name.Length);
var inv = FindUser(id).Bind(u => BuildInvoice(u.Id, itemId));
It also offers LINQ query syntax (from … from … select), including an async form over
ValueTask<Outcome<T>>:
var result = from u in FindUser(id)
from i in FindItem(itemId)
select new Invoice(u, i);
⚠️ Caution: the LINQ form is not reliable for
Outcome<T>— prefer the guard style. The query syntax compiles into nestedSelectMany/Selectclosures and, in the async case, a generated state machine. In practice this has broken under code that participates in ambient, flow-sensitive state — for example operations running inside a MongoDB transaction, where the LINQ-generated state machine appears to disrupt the transaction context and the call fails. Until this is understood and fixed, treat LINQ overOutcome<T>as experimental: use theSuccess/Failguard pattern (above) for anything inside a transaction or other context-sensitive scope.
Helper cheat-sheet
| Need | Use |
|---|---|
| Make a success / failure | SuccessOutcome(v) / FailedOutcome<T>(e) (or just return v; / return new ErrorInfo(code, msg);) |
| Happy-path guard | if (Success(x, out var v, out var e)) |
| Forward an error early | if (Fail(x, out var e, out var v)) return e.Trace(); |
| Treat not-found as non-error | FailButNotFound(x, out var e, out var v) |
Turn a throwing call into an Outcome |
TryCatch(() => ...) / TryCatch(async ...) |
| Collapse to a value | x.Match[README.md](src/RZ.Foundation/README.md)(onOk, onErr) / x.IfFail(default) |
| Exit to exception-based code | x.Unwrap() / await ThrowIfError(task) |
| Collect an async stream | await TryCatch(stream).MakeList() / .MakeMutableList() |
| Build a structured error | ErrorInfo.New(code, message) / StandardErrorCodes |
| Add app-level trace while propagating | error.Trace() |
| Wrap a low-level error in context | error.Wrap(code, message) |
Encryption (AES-GCM)
RZ.Foundation.Helpers.Encryption provides authenticated symmetric encryption built on AES-GCM.
Encrypt and Decrypt return Outcome<byte[]>, so failures — a wrong key, a malformed or tampered
payload, or a platform without AES-GCM — flow through the railway instead of throwing.
AES-GCM gives two guarantees for free:
- Confidentiality — a fresh random 12-byte nonce is generated on every
Encrypt, so encrypting the same data twice produces different output. - Integrity / authenticity — every payload carries an authentication tag. If the payload is modified,
or decrypted with the wrong key,
Decryptfails with theEncryption.TAMPER_ERRORcode rather than returning corrupt bytes.
The produced payload is self-describing, laid out as [nonce (12 bytes)][tag (16 bytes)][ciphertext], so
the only thing you need to keep is the key.
Getting a key
A key must be 16, 24 or 32 bytes (AES-128/192/256).
using RZ.Foundation.Helpers;
// A true random 256-bit key (store it somewhere safe):
byte[] key = Encryption.RandomAesKey();
To derive the key deterministically from a string — so it can be re-created on demand and never stored — pick the method that matches your input. The name makes the security choice explicit:
// For a human passphrase / low-entropy text — PBKDF2-HMAC-SHA256 (slow, adds brute-force resistance):
byte[] key = Encryption.CreateAesKeyFromWeakText("correct horse battery staple").Unwrap();
// For text that is ALREADY high-entropy (e.g. a generated / base64 secret) — HKDF-SHA256 (fast):
byte[] key = Encryption.CreateAesKeyFromStrongText(mySecretToken).Unwrap();
// Both default to a 32-byte (AES-256) key; pass n = 16 or 24 for AES-128 / AES-192.
Deterministic derivation is only as strong as the entropy of the input string. For a key that does not need to be reproducible from text, prefer
RandomAesKey().
Encrypting and decrypting
using System.Text;
using RZ.Foundation; // Outcome<T>
using RZ.Foundation.Helpers;
byte[] key = Encryption.RandomAesKey();
byte[] data = Encoding.UTF8.GetBytes("secret message");
Outcome<byte[]> roundTrip =
Encryption.Encrypt(key, data)
.Bind(payload => Encryption.Decrypt(key, payload));
string text = roundTrip.Match(
plain => Encoding.UTF8.GetString(plain),
error => $"failed: {error.Message}");
Detecting tampering
var payload = Encryption.Encrypt(key, data).Unwrap();
payload[^1] ^= 0xFF; // flip a byte of the ciphertext
var result = Encryption.Decrypt(key, payload);
if (result.IfFail(out var error) && error.Is(Encryption.TAMPER_ERROR))
Console.WriteLine("Payload was tampered with (or the key is wrong).");
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- JetBrains.Annotations (>= 2026.2.0)
- LanguageExt.Core (>= 4.4.9)
- RZ.Foundation.AOT (>= 8.3.0)
NuGet packages (14)
Showing the top 5 NuGet packages that depend on RZ.Foundation:
| Package | Downloads |
|---|---|
|
RZ.Foundation.NewtonsoftJson
RZ.Foundation extension for Newtonsoft.JSON |
|
|
TiraxTech.Uri
Customizable, Immutable URI record |
|
|
RZ.Foundation.Blazor
Package Description |
|
|
RZ.Linq.RelationalDatabase
Transform LINQ to SQL text |
|
|
RZ.AspNet.Bootstrapper
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 8.3.0 | 129 | 6/29/2026 |
| 8.2.15 | 132 | 4/26/2026 |
| 8.2.14 | 155 | 3/29/2026 |
| 8.2.13 | 127 | 3/28/2026 |
| 8.2.12 | 129 | 3/27/2026 |
| 8.2.11 | 141 | 3/24/2026 |
| 8.2.10 | 115 | 3/23/2026 |
| 8.2.9 | 479 | 2/7/2026 |
| 8.2.6 | 266 | 1/30/2026 |
| 8.1.0 | 220 | 1/28/2026 |
| 7.0.5 | 537 | 6/12/2025 |
| 7.0.4 | 297 | 4/24/2025 |
| 7.0.2 | 366 | 3/9/2025 |
| 7.0.0 | 287 | 2/4/2025 |
| 7.0.0-beta.26 | 128 | 10/15/2024 |
| 7.0.0-beta.12 | 122 | 9/25/2024 |
| 7.0.0-beta.11 | 135 | 9/22/2024 |
| 7.0.0-beta.3 | 148 | 6/23/2024 |
| 7.0.0-alpha.3 | 143 | 6/22/2024 |
| 6.5.0-beta.1 | 139 | 5/9/2024 |
Embrace LanguageExt lib.