Resultly 0.7.0

dotnet add package Resultly --version 0.7.0
                    
NuGet\Install-Package Resultly -Version 0.7.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Resultly" Version="0.7.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Resultly" Version="0.7.0" />
                    
Directory.Packages.props
<PackageReference Include="Resultly" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Resultly --version 0.7.0
                    
#r "nuget: Resultly, 0.7.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Resultly@0.7.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Resultly&version=0.7.0
                    
Install as a Cake Addin
#tool nuget:?package=Resultly&version=0.7.0
                    
Install as a Cake Tool

Resultly

Build Status NuGet NuGet Downloads License: MIT .NET

High-performance functional Result types for .NET โ€” explicit error handling without exceptions.

Resultly implements Railway Oriented Programming, providing type-safe error handling through discriminated unions. It eliminates the need for try-catch blocks and makes error paths explicit in your type system.

Features

  • ๐Ÿš€ Zero allocation โ€” struct-based design with aggressive inlining

  • ๐Ÿ”’ Type-safe โ€” closed type hierarchy with exhaustive pattern matching

  • ๐Ÿ›ค๏ธ Railway Oriented Programming โ€” compose operations that short-circuit on errors

  • โšก Async/await support โ€” full async variants of all operations

  • ๐Ÿ“ฆ Collection operations โ€” Traverse, Sequence, Combine for working with multiple Results

  • ๐Ÿงฉ Monadic โ€” Map, Bind, Then, and other functional operators

  • ๐ŸŽฏ Validation โ€” Ensure and EnsureAll for domain validation

  • ๐Ÿ”„ Error recovery โ€” OrElse, Recover, and fallback mechanisms

  • ๐ŸŽญ Multiple Error Types โ€” Result with up to 4 specific error types visible in signature

  • ๐Ÿงพ Canonical ResultError โ€” Result<TSuccess> facade with structured errors (message/code/metadata)

  • ๐Ÿ›ก๏ธ Diagnostics Guards โ€” detect uninitialized Result<T> instances with sync/Task/ValueTask helpers

  • ๐Ÿท๏ธ Semantic Match โ€” Source-generated semantic parameter names with sync and async overloads (works for Result and Task<Result> across every arity)

  • ๐Ÿ” Type-Based Error Extraction โ€” TryGetError<T>() for extracting errors by type

  • โœจ Convenience helpers โ€” AnyError / AnyErrorAsync and TryGetAnyError to quickly return or inspect any error from multi-error Result types without writing exhaustive Match at call sites

  • ๐Ÿ”€ Pure Discriminated Unions โ€” Either types for type-safe unions without success/error semantics, with descriptive onFirst/onSecond handlers and optional semantic names

  • Comprehensive โ€” extensive XML documentation and 300+ tests with high code coverage

  • โœ… 4-arity async-fluent MapErrors โ€” full parity for 4-error Result types: you can fluently call WithError1Async/WithError2Async/WithError3Async/WithError4Async in any order to build mapping pipelines and then call ToEitherAsync(); note the mapping order is determined by the final builder shape (the builder arranges mapped slots in the resulting Either in a deterministic order based on which With* was applied).

  • ๐Ÿงฐ MatchErrors convenience helpers โ€” thin overloads such as result.MatchErrors(onError1, onError2) provide a compact way to map a selected subset of error slots directly to an Either<...> without manually composing the full fluent builder chain. Use these for ergonomic, concise error mappings in call sites and examples.

Installation

dotnet add package Resultly

Supports: .NET 9.0 | .NET 8.0 (LTS) | .NET Standard 2.1

Quick Start

Example Project

An end-to-end sample console app lives in Examples/Resultly.ExampleApp. It is part of the solution for easy F5 debugging, yet it is marked as IsPackable=false so it never ships with the NuGet package. The app walks through synchronous/async pipelines, semantic matching, collection helpers, and pure Either unions using the new semantic match helpers. Build or run it directly to exercise the public surface:

dotnet run --project Examples/Resultly.ExampleApp/Resultly.ExampleApp.csproj

If you add new APIs, wiring them into this sample helps the compiler catch integration issues early.

Basic Usage

using Resultly;

// Create Results using implicit conversions
Result<int, string> success = 42;
Result<int, string> failure = "Not found";

// Pattern matching
var message = success.Match(
    onSuccess: value => $"Got {value}",
    onError: error => $"Error: {error}"
);

Canonical Result<T>

Result<TSuccess> is a thin wrapper over Result<TSuccess, ResultError> that gives you a structured error payload (message, optional code, optional metadata) without having to declare a custom error type.

// Successful result
var quotient = Result<int>.Success(42);

// Structured failure (message + code + metadata)
var failure = Result<int>.Failure(
    message: "Division by zero",
    code: "DIVIDE_BY_ZERO",
    metadata: new Dictionary<string, string?> { ["denominator"] = "0" }
);

// Semantic match works for Result<T>
var message = failure.MatchSemantic(
    onSuccess: value => $"Quotient: {value}",
    onResultError: error => $"Calculation failed ({error.Code ?? "ERR"}): {error.Message}"
);

// Async overloads are available too
var audit = await failure.MatchSemanticAsync(
    async value => { await LogAsync(value); return "ok"; },
    async error => { await LogErrorAsync(error); return "fail"; }
);

// Metadata helpers let you update context without rebuilding dictionaries
var traceId = Guid.NewGuid().ToString("N");
var correlationId = traceId.ToUpperInvariant();
var enriched = failure.AddMetadata("operation", "Divide");
var replaced = failure
    .AddMetadataRange(new Dictionary<string, string?> { ["traceId"] = traceId })
    .RemoveMetadata("operation")
    .WithMetadata(
        new Dictionary<string, string?> { ["correlationId"] = correlationId },
        StringComparer.OrdinalIgnoreCase
    );

// Default(Result<T>) surfaces ResultError.Uninitialized for easy guard clauses
if (!default(Result<int>).IsInitialized())
{
    Console.WriteLine("Result<int> was not initialized before use.");
}

// ThrowIfUninitialized() provides a straightforward guard for API entry points
var result = default(Result<int>);
result.ThrowIfUninitialized(); // throws InvalidOperationException with a descriptive message

// ValueTask overloads are available when the result is produced asynchronously
var pending = new ValueTask<Result<int>>(Result<int>.Success(5));
await pending.ThrowIfUninitialized();

Multiple Specific Error Types

Define Results with multiple explicit error types:

public record User(string Name, string Email);
public record NotFoundError(string Message);
public record DatabaseError(string Message);
public record ValidationError(string Message);

// All error types visible in the signature
public Result<User, NotFoundError, DatabaseError> GetUser(int id)
{
    if (id < 0)
        return new ValidationError("Invalid ID");  // Won't compile - not in signature!

    if (!_db.Exists(id))
        return new NotFoundError($"User {id} not found");

    try
    {
        return _db.GetUser(id);
    }
    catch
    {
        return new DatabaseError("Connection failed");
    }
}

// Use semantic Match - generated automatically!
var result = GetUser(42);
result.MatchSemantic(
    onSuccess: user => Console.WriteLine($"Hello, {user.Name}"),
    onNotFoundError: err => Console.WriteLine($"404: {err.Message}"),
    onDatabaseError: err => Console.WriteLine($"DB Error: {err.Message}")
);

// Async version preserves semantic naming across awaits
var greeting = await result.MatchSemanticAsync(
    async user =>
    {
        await emailService.SendWelcome(user);
        return $"Sent email to {user.Email}";
    },
    async notFound =>
    {
        await telemetry.TrackMiss(notFound.Message);
        return $"Missing {notFound.Message}";
    },
    async dbError =>
    {
        await telemetry.TrackFailure(dbError.Message);
        return $"Database issue {dbError.Message}";
    }
);

// Or extract errors by type
if (result.TryGetError<DatabaseError>(out var dbError))
{
    _logger.LogError("Database issue: {Message}", dbError.Message);
    // Retry logic here
}

Benefits:

  • Error types are visible in the method signature
  • IntelliSense shows all possible error types
  • Semantic Match parameter names (generated automatically via source generator)
  • Type-safe error extraction with TryGetError<T>()
  • Compiler enforces handling of all error cases

Either Types (Pure Discriminated Unions)

Sometimes you need type-safe unions without success/error semantics. Either types provide pure discriminated unions where all cases are semantically equal:

using Resultly;

// Either2: A value that is one of two types
public record UserInput();
public record AdminInput();

public Either<UserInput, AdminInput> ParseInput(string role, string data)
{
    return role == "admin"
        ? Either<UserInput, AdminInput>.Second(new AdminInput())
        : Either<UserInput, AdminInput>.First(new UserInput());
}

// Pattern match all cases
var result = ParseInput("user", "data");
result.Match(
    onFirst: userInput => ProcessUserInput(userInput),
    onSecond: adminInput => ProcessAdminInput(adminInput)
);

// Or use MatchSemantic to stick to type-based names
result.MatchSemantic(
    onUserInput: user => ProcessUserInput(user),
    onAdminInput: admin => ProcessAdminInput(admin)
);

Either3/4/5 for Multiple Types:

// Payment methods
public record CreditCard(string Number);
public record PayPal(string Email);
public record BankTransfer(string IBAN);
public record Cryptocurrency(string Wallet);

public Either<CreditCard, PayPal, BankTransfer, Cryptocurrency> GetPaymentMethod()
{
    // Return any of the four types
    return Either<CreditCard, PayPal, BankTransfer, Cryptocurrency>
        .Third(new BankTransfer("DE89370400440532013000"));
}

// Exhaustive matching required
paymentMethod.Match(
    onFirst: cc => ProcessCreditCard(cc),
    onSecond: pp => ProcessPayPal(pp),
    onThird: bt => ProcessBankTransfer(bt),
    onFourth: crypto => ProcessCrypto(crypto)
);

Type Extraction:

// Try to extract a specific type
if (paymentMethod.TryPick<PayPal>(out var paypal))
{
    Console.WriteLine($"PayPal: {paypal.Email}");
}

// Or get all values as tuples
var (card, paypal, bank, crypto) = paymentMethod.Get();
// Only one will be non-null

Practical Use Cases:

// 1. Configuration sources
public Either<JsonConfig, XmlConfig, YamlConfig> LoadConfig(string path)
{
    var ext = Path.GetExtension(path);
    return ext switch
    {
        ".json" => Either<JsonConfig, XmlConfig, YamlConfig>.First(ParseJson(path)),
        ".xml" => Either<JsonConfig, XmlConfig, YamlConfig>.Second(ParseXml(path)),
        ".yaml" => Either<JsonConfig, XmlConfig, YamlConfig>.Third(ParseYaml(path)),
        _ => throw new NotSupportedException()
    };
}

// 2. Protocol messages
public record HttpRequest();
public record WebSocketMessage();
public record GrpcCall();

public Either<HttpRequest, WebSocketMessage, GrpcCall> ReceiveMessage()
{
    // Return the appropriate message type
}

// 3. State machines
public record Idle();
public record Processing();
public record Completed();
public record Failed();

public Either<Idle, Processing, Completed, Failed> GetCurrentState()
{
    // Return current state without success/error connotation
}

Key Differences from Result:

Feature Result Either
Semantics Success vs. Error Equal alternatives
Short-circuiting Operators stop on Error No special behavior
Use case Error handling Type unions, state machines
Pattern matching onSuccess/onError onFirst/onSecond/onThird...
Operators Map, Bind, Then, etc. Match, TryPick, Get

When to use Either vs Result:

  • Use Result when modeling operations that can fail (database queries, validations, IO)
  • Use Either when modeling a choice between semantically equal alternatives (config formats, message types, state machines)

Railway Oriented Programming

Chain operations that automatically short-circuit on errors:

var result = GetUserEmail(userId)
    .Bind(ValidateEmail)
    .Map(email => email.ToLower())
    .Bind(SendNotification);

LINQ Query Syntax

var result =
    from user in GetUser(id)
    from profile in GetProfile(user.ProfileId)
    where profile.IsActive
    select new UserViewModel(user, profile);

Core Concepts

Chain operations that automatically short-circuit on the first error:

var result = ValidateInput(input)
    .Then(ParseValue)
    .Ensure(x => x > 0, _ => "Must be positive")
    .Map(x => x * 2)
    .Then(SaveToDatabase)
    .MapError(error => $"Pipeline failed: {error}");

// Handle the result
result.Match(
    onSuccess: id => Console.WriteLine($"Saved with ID: {id}"),
    onError: error => Console.WriteLine($"Error: {error}")
);

Core Operations

Construction

// Implicit conversions
Result<int, string> success = 42;
Result<int, string> failure = "error";

// Static factory methods
Result<int, string>.Success(42)
Result<int, string>.Failure("error")

// Lift regular values
Result<int, string>.FromValue(42)

Map

Transform success values while preserving errors:

Result<int, string> result = 42;
var doubled = result.Map(x => x * 2);  // Ok(84)

// Async variant
var asyncResult = await result.MapAsync(async x => await ComputeAsync(x));

Bind (FlatMap)

Chain Result-returning functions without nesting:

Result<string, string> GetUserId(string email) { /* ... */ }
Result<User, string> LoadUser(string userId) { /* ... */ }

var user = GetUserId("user@example.com")
    .Bind(LoadUser);

// Async variant
var asyncUser = await GetUserIdAsync(email)
    .BindAsync(async id => await LoadUserAsync(id));

Then

Railway-style continuation (alias for Bind with clearer intent):

var result = ParseInput(input)
    .Then(Validate)
    .Then(Transform)
    .Then(Persist);

// Async variant
var asyncResult = await ParseInputAsync(input)
    .ThenAsync(ValidateAsync)
    .ThenAsync(TransformAsync);

Ensure & EnsureAll

Validate and convert to error if predicate fails:

var result = Result<int, string>.Success(150)
    .Ensure(x => x >= 0, _ => "Must be non-negative")
    .Ensure(x => x <= 100, x => $"{x} exceeds maximum");

// EnsureAll: Apply multiple validations, collect all errors
var validated = result.EnsureAll(
    (x => x >= 0, _ => "Must be non-negative"),
    (x => x <= 100, _ => "Must not exceed 100"),
    (x => x % 2 == 0, _ => "Must be even")
);

Match

Exhaustively handle both cases:

// Match with return value
var output = result.Match(
    onSuccess: value => ProcessValue(value),
    onError: error => HandleError(error)
);

// Match with side effects (void)
result.Match(
    onSuccess: value => Console.WriteLine(value),
    onError: error => Console.Error.WriteLine(error)
);

// MatchSemantic for Result3/4/5 (source-generated)
result.MatchSemantic(
    onSuccess: user => HandleSuccess(user),
    onNotFoundError: err => HandleNotFound(err),
    onDatabaseError: err => HandleDatabase(err)
);

// Async variant (Result or Task<Result>)
var summary = await result.MatchSemanticAsync(
    async user => await BuildSummary(user),
    async notFound => await HandleNotFoundAsync(notFound),
    async dbError => await HandleDatabaseAsync(dbError)
);

Type Extraction

// Extract specific error type from Result with multiple errors
if (result.TryGetError<DatabaseError>(out var dbError))
{
    _logger.LogError("Database issue: {Message}", dbError.Message);
}

// For Either types
if (either.TryPick<PayPal>(out var paypal))
{
    ProcessPayPal(paypal);
}

// Get all values as tuple (only one non-null)
var (first, second, third) = either.Get();

Async Support

All operations have async variants:

var result = await ValidateAsync(input)
    .ThenAsync(async x => await SaveAsync(x))
    .MapAsync(async x => await TransformAsync(x));

Complete Operator Reference

Transformation Operators

  • Map / MapAsync - Transform success value
  • MapError - Transform error value
  • Bind / BindAsync - Chain Result-returning operations (flatMap)
  • Then / ThenAsync - Alias for Bind with clearer semantic intent

Validation Operators

  • Ensure - Single validation with predicate
  • EnsureAll - Multiple validations, collect all errors
  • Where - LINQ filter support

Error Recovery Operators

  • OrElse / OrElseAsync - Provide fallback on error
  • Recover - Convert error to success value
  • DefaultWith - Provide default value on error
  • DefaultIfError - Use default value from function

Side Effect Operators

  • Do - Execute action on success without transformation
  • DoWhenError - Execute action on error without transformation
  • DoAsync / DoWhenErrorAsync - Async variants

Inspection Operators

  • Match / MatchAsync - Exhaustive pattern matching with return value
  • MatchSemantic - Type-specific semantic matching (generated)
  • TryGetError<T> - Extract specific error type
  • TryPick<T> - Extract specific type from Either
  • Get - Get all values as tuple (Either types)

Query Operators (LINQ)

  • Select / SelectMany - LINQ query syntax support
  • Where - Filter with predicate

Conversion Operators

  • ToEither - Convert Result to Either
  • ToOption - Convert to Option/Maybe type (if implemented)

Note: the canonical conversion APIs are ToEither and the MapErrors() builder + ToEither() terminal. A number of small convenience wrappers (for example MapToEither / MapSuccessToEither and a few thin MapToEitherE1E2/MapToEitherE2E3 helpers) were removed to keep the API surface focused. Use one of the following migration patterns:

  • Convert a single error slot: result.ToEither(onError1) or await result.ToEitherAsync(onError1Async)
  • Convert two slots into a two-case Either: result.ToEitherMap(OnFirstError: e1 => left, OnSecondError: e2 => right)
  • Use the fluent builder to map selected slots: result.MapErrors().WithError1(e1 => map1).WithError2(e2 => map2).ToEither()

For ergonomic fluent chains the lightweight top-level forwarding helpers WithError1/WithError2/WithError3/WithSuccess still forward to MapErrors() so existing short-call call sites like result.WithError1(...).WithError3(...).ToEither() continue to work.

Type Hierarchy

Result Types

// Result<TSuccess, TError> - Single error type
Result<User, string> user = GetUser(id);

// Result<TSuccess, TError1, TError2> - Two error types
Result<User, NotFoundError, DatabaseError> user = GetUser(id);

// Result<TSuccess, TError1, TError2, TError3> - Three error types
Result<User, NotFoundError, DatabaseError, ValidationError> user = GetUser(id);

// Result<TSuccess, TError1, TError2, TError3, TError4> - Four error types
Result<User, NotFoundError, DatabaseError, ValidationError, TimeoutError> user = GetUser(id);

Either Types

// Either<T1, T2> - Two alternatives
Either<JsonConfig, XmlConfig> config = LoadConfig();

// Either<T1, T2, T3> - Three alternatives
Either<JsonConfig, XmlConfig, YamlConfig> config = LoadConfig();

// Either<T1, T2, T3, T4> - Four alternatives
Either<CreditCard, PayPal, BankTransfer, Cryptocurrency> payment = GetPayment();

// Either<T1, T2, T3, T4, T5> - Five alternatives
Either<Idle, Processing, Completed, Failed, Cancelled> state = GetState();

Construction Examples

// Result construction
var success = Result<int, string>.Success(42);
var failure = Result<int, string>.Failure("error");

// Implicit conversion
Result<int, string> r1 = 42;        // Success
Result<int, string> r2 = "error";   // Failure

// Multi-error Result construction
var notFound = Result<User, NotFoundError, DatabaseError>.Error1(new NotFoundError());
var dbError = Result<User, NotFoundError, DatabaseError>.Error2(new DatabaseError());

// Either construction
var first = Either<string, int>.First("hello");
var second = Either<string, int>.Second(42);

// Multi-type Either
var json = Either<JsonConfig, XmlConfig, YamlConfig>.First(config);
var xml = Either<JsonConfig, XmlConfig, YamlConfig>.Second(config);
var yaml = Either<JsonConfig, XmlConfig, YamlConfig>.Third(config);

Collection Operations

Sequence

Combine multiple Results, short-circuiting on first error:

var results = new[]
{
    ParseInt("1"),
    ParseInt("2"),
    ParseInt("3")
};

Result<IReadOnlyList<int>, string> combined = results.Sequence();
// Ok([1, 2, 3])

// If any fails, returns first error
var withError = new[] { ParseInt("1"), ParseInt("bad"), ParseInt("3") };
var failed = withError.Sequence(); // Error("Invalid number")

Traverse

Apply a Result-returning function to each element:

var numbers = new[] { "1", "2", "3" };
var result = numbers.Traverse(ParseInt);
// Ok([1, 2, 3])

// Short-circuits on first error
var mixed = new[] { "1", "bad", "3" };
var failed = mixed.Traverse(ParseInt); // Error on "bad"

Combine

Collect all errors instead of short-circuiting:

var validations = new[]
{
    ValidateEmail(email),
    ValidateAge(age),
    ValidatePassword(password)
};

var results = validations.Combine();
// Returns all validation errors if any fail
// Success only if all succeed

Async Collection Operations

// SequenceAsync
var asyncResults = new[] { GetUserAsync(1), GetUserAsync(2) };
var combined = await asyncResults.SequenceAsync();

// TraverseAsync
var ids = new[] { 1, 2, 3 };
var users = await ids.TraverseAsync(async id => await GetUserAsync(id));

Error Handling & Recovery

MapError

Transform error types:

var result = operation()
    .MapError(ex => new ValidationError(ex.Message));

// Change error type
Result<int, DatabaseError> dbResult = GetFromDatabase();
Result<int, string> stringResult = dbResult.MapError(e => e.Message);

OrElse

Provide alternative on error:

var result = TryPrimarySource()
    .OrElse(error => TrySecondarySource())
    .OrElse(error => TryTertiarySource());

// Async variant
var asyncResult = await TryPrimaryAsync()
    .OrElseAsync(async err => await TrySecondaryAsync());

Recover

Convert errors to success values:

var result = RiskyOperation()
    .Recover(error => DefaultValue);

// Conditional recovery
var recovered = operation()
    .Recover(error => error switch
    {
        NotFoundError => DefaultUser,
        TimeoutError => CachedUser,
        _ => throw new InvalidOperationException()
    });

Do & DoWhenError

Execute side effects without transforming the Result:

var result = operation()
    .Do(value => _logger.LogInformation("Success: {Value}", value))
    .DoWhenError(error => _logger.LogError("Failed: {Error}", error));

// Continues the pipeline
result.Do(LogSuccess).DoWhenError(LogError).Map(Transform);

Practical Example

public record User(int Id, string Email, int Age);
public record ValidationError(string Field, string Message);

public Result<User, ValidationError> CreateUser(string email, int age)
{
    return ValidateEmail(email)
        .Bind(validEmail => ValidateAge(age)
            .Map(validAge => new User(0, validEmail, validAge)))
        .Then(SaveUser);
}

Result<string, ValidationError> ValidateEmail(string email)
{
    return email.Contains("@")
        ? Result<string, ValidationError>.Success(email)
        : Result<string, ValidationError>.Failure(
            new ValidationError("Email", "Invalid format"));
}

Result<int, ValidationError> ValidateAge(int age)
{
    return age >= 18
        ? Result<int, ValidationError>.Success(age)
        : Result<int, ValidationError>.Failure(
            new ValidationError("Age", "Must be 18 or older"));
}

Result<User, ValidationError> SaveUser(User user)
{
    // Database operation
    return Result<User, ValidationError>.Success(user with { Id = 123 });
}

// Usage
var result = CreateUser("user@example.com", 25);
result.Match(
    onSuccess: user => Console.WriteLine($"Created user {user.Id}"),
    onError: error => Console.WriteLine($"{error.Field}: {error.Message}")
);

ASP.NET Core Integration

[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest request)
{
    var result = ValidateRequest(request)
        .Then(CreateUserFromRequest)
        .Then(SaveToDatabase);

    return result.Match(
        onSuccess: user => Ok(new { userId = user.Id }),
        onError: error => BadRequest(new { error = error.Message })
    );
}

Philosophy

Resultly embraces these principles:

  1. Make invalid states unrepresentable โ€” errors are explicit in the type system
  2. Fail fast and explicitly โ€” no hidden exceptions, all failures are values
  3. Compose operations โ€” build complex logic from simple, composable parts
  4. Railway Oriented Programming โ€” success and failure paths are separate tracks

Performance

Resultly is designed for zero-allocation scenarios:

  • Struct-based types where possible
  • Aggressive inlining via [MethodImpl]
  • No reflection or dynamic dispatch
  • Minimal boxing/unboxing

Comparison with Exceptions

Traditional exception handling:

try
{
    var user = GetUser(id);
    var validated = ValidateUser(user);
    var saved = SaveUser(validated);
    return saved;
}
catch (NotFoundException ex)
{
    // Handle not found
}
catch (ValidationException ex)
{
    // Handle validation
}
catch (DatabaseException ex)
{
    // Handle database error
}

With Resultly:

return GetUser(id)
    .Then(ValidateUser)
    .Then(SaveUser)
    .MapError(error => /* handle all errors uniformly */);

License

MIT License - see LICENSE for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Formatting Hook

Run scripts/install-format-hook.ps1 once to wire up a pre-commit hook that executes dotnet msbuild -target:FormatAll. The hook blocks the commit if formatting fails or produces additional changes, making it easier to keep the repo consistent:

pwsh scripts/install-format-hook.ps1

Credits

Inspired by:

  • F# Result type
  • Rust's Result<T, E>
  • Railway Oriented Programming by Scott Wlaschin
  • Functional programming patterns in Haskell and Scala

Semantic Match Extensions (New Package)

Note: As of November 2025, semantic match extension methods are distributed in a separate package:

  • Resultly โ€” core types only (no generator, no semantic match extensions)
  • Resultly.Extensions.SemanticMatch โ€” add this package to get semantic match extensions for all Resultly types (includes the generator)

To use semantic match extensions, reference only Resultly.Extensions.SemanticMatch in your project:

dotnet add package Resultly.Extensions.SemanticMatch

This package depends on Resultly and includes the source generator. Do not reference Resultly.Generators directly.

This structure avoids ambiguous extension methods and ensures only one set of semantic match extensions is generated per build.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.7.0 213 11/13/2025
0.5.0 212 11/13/2025
0.4.0 214 11/13/2025
0.3.0 215 11/13/2025
0.2.4 251 11/11/2025
0.2.3 182 11/9/2025
0.2.0-alpha.8 144 11/9/2025
0.2.0-alpha.7 147 11/9/2025
0.2.0-alpha.6 142 11/9/2025
0.2.0-alpha.5 141 11/3/2025
0.2.0-alpha.4 129 11/3/2025
0.2.0-alpha.2 130 11/2/2025
0.2.0-alpha.1 138 11/2/2025
0.1.0-alpha.3 70 11/2/2025
0.1.0-alpha.1 70 11/2/2025