Skip to content

Troubleshooting

Practical problem/solution guide for Pragmatic.Result. Each section covers a common issue, the likely causes, and the fix.


InvalidOperationException: Cannot Access Value

Section titled “InvalidOperationException: Cannot Access Value”

Your code throws InvalidOperationException with the message “Cannot access Value when result is failure.”

  1. Did you check IsSuccess before accessing .Value? This is the most common cause. The Result is in a failure state, but you are reading the success value:

    // Throws if result is failure
    var user = result.Value;
    // Safe: check first
    if (result.IsSuccess)
    var user = result.Value;
  2. Use TryGetValue() for the TryParse pattern:

    if (result.TryGetValue(out var user))
    Console.WriteLine(user.Name);
  3. Use Match() for exhaustive handling:

    var name = result.Match(
    user => user.Name,
    error => "Unknown");
  4. Check the PRAG0001 analyzer. The Roslyn analyzer warns at build time when .Value is accessed without a guard. If PRAG0001 is not firing, verify that Pragmatic.Result.Analyzers is referenced.


InvalidOperationException: Cannot Access Error

Section titled “InvalidOperationException: Cannot Access Error”

Your code throws InvalidOperationException with the message “Cannot access Error when result is success.”

  1. Did you check IsFailure before accessing .Error? The Result is in a success state, but you are reading the error:

    // Throws if result is success
    var error = result.Error;
    // Safe: check first
    if (result.IsFailure)
    Log(result.Error);
  2. Use TryGetError() for safe access:

    if (result.TryGetError(out var error))
    logger.LogWarning("Failed: {Code}", error.Code);

The compiler reports CS0121: The call is ambiguous between two implicit conversions.

TValue and TError are the same type. If both type parameters resolve to the same type, the compiler cannot determine which implicit conversion to use:

// Ambiguous: string could be success value or error code
Result<string, StringError> result = "hello"; // Which conversion?

Fix: Use the explicit factory method:

var result = Result<string, StringError>.Success("hello");
var result = Result<string, StringError>.Failure(new StringError("oops"));

TValue is assignable to TError (or vice versa). This happens when using base types like IError:

Result<IError, IError> result = someError; // Ambiguous

Fix: Avoid using the same type or overlapping types for TValue and TError. If unavoidable, use explicit factories.


Your API returns raw Result JSON ({ "isSuccess": false, "error": {...} }) instead of ProblemDetails.

  1. For Minimal APIs: Did you add WithResultHandling() to the route group?

    var api = app.MapGroup("").WithResultHandling();
  2. For Controllers: Did you register ResultActionFilter?

    builder.Services.AddControllers(options =>
    {
    options.Filters.Add<ResultActionFilter>();
    });
  3. Did you register AddPragmaticResult()?

    builder.Services.AddPragmaticResult();
  4. Is [SkipResultHandling] applied? This attribute explicitly opts out of automatic Result-to-HTTP conversion. Remove it if you want ProblemDetails.

  5. Are you using Pragmatic.Endpoints? When using [Endpoint], Result-to-HTTP conversion is handled by the SG-generated handler. You do not need ResultActionFilter or WithResultHandling() — the SG generates the mapping code directly.


The API returns 422 instead of the expected status code (e.g., 404, 409).

Custom error missing StatusCode. When an error implements only IError without a StatusCode property, the ASP.NET Core integration defaults to 422 for unknown error types:

// Missing StatusCode -- defaults to 422
public record PaymentRequiredError : IError
{
public string Code => "PAYMENT_REQUIRED";
}
// Fixed: add StatusCode
public record PaymentRequiredError : IError
{
public string Code => "PAYMENT_REQUIRED";
public int StatusCode => 402;
}

Wrong error type returned. Verify the error instance you are returning. If you declare Result<T, NotFoundError> but return a different error type via implicit conversion from a shared method, the status code reflects the actual error type, not the declared type.

Built-in error with correct status. Verify the error type’s status code:

Error TypeStatus Code
BadRequestError400
UnauthorizedError401
ForbiddenError403
NotFoundError404
ConflictError409
BusinessRuleError422
InternalServerError500
DependencyError502, 503, or 504

You reference Result<T, E1, E2> but the compiler does not recognize the type.

  1. Is the Pragmatic.Result package referenced? The multi-error variants are generated by the package’s source generator. Verify the package reference in your .csproj.

  2. Clean and rebuild. Source generator output can become stale. Run:

    Terminal window
    dotnet clean
    dotnet build
  3. Check the SG output. In Visual Studio, expand Dependencies > Analyzers > Pragmatic.Result.SourceGenerator in Solution Explorer. You should see files like Result3_Generated.g.cs through Result9_Generated.g.cs.

  4. Maximum 8 error types. The SG generates variants up to Result<TValue, TError1, ..., TError8>. If you need more than 8 error types, consider aggregating related errors into a single type or using AggregateError.


Match Not Compiling (Wrong Number of Handlers)

Section titled “Match Not Compiling (Wrong Number of Handlers)”

The call to Match() does not compile because the number of handler lambdas does not match the number of type parameters.

Match() requires exactly N+1 handlers: one for the success value, plus one per error type.

// Result<T, E1, E2> requires 3 handlers:
result.Match(
value => ..., // Success handler
error1 => ..., // TError1 handler
error2 => ...); // TError2 handler
// Result<T, E1> requires 2 handlers:
result.Match(
value => ..., // Success handler
error => ...); // TError handler
// VoidResult<E> requires 2 handlers, but success has no parameter:
result.Match(
() => ..., // Success (no value)
error => ...); // TError handler

If you are using a multi-error Result and forgot a handler, the compiler error message will indicate which overload could not be resolved. Count the error type parameters and add the missing handler.


Result types serialize as {} or throw JsonException.

  1. Register ResultJsonConverterFactory:

    // For Minimal APIs
    builder.Services.ConfigureHttpJsonOptions(options =>
    {
    options.SerializerOptions.Converters.Add(new ResultJsonConverterFactory());
    });
    // For Controllers
    builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
    options.JsonSerializerOptions.Converters.Add(new ResultJsonConverterFactory());
    });
    // For standalone serialization
    var options = new JsonSerializerOptions();
    options.Converters.Add(new ResultJsonConverterFactory());
  2. Check the JSON structure for deserialization. The expected wire format is:

    // Success
    { "isSuccess": true, "value": { ... } }
    // Failure
    { "isSuccess": false, "error": { "code": "NOT_FOUND", ... } }

    If the isSuccess property is missing, deserialization will fail.

  3. Custom error types must be serializable. Properties on your custom error must have public getters for System.Text.Json to include them.


ProblemDetails Missing Error-Specific Fields

Section titled “ProblemDetails Missing Error-Specific Fields”

The ProblemDetails response has type, title, status, and code but is missing fields like entityType, entityId, or reason.

  1. Use the factory methods on built-in errors. Fields like EntityType and EntityId are populated by the factory methods:

    // These populate ProblemDetails extensions
    NotFoundError.Create("User", userId); // entityType + entityId
    ConflictError.AlreadyExists("User", email); // entityType + reason
    BadRequestError.MissingHeader("X-Api-Key"); // field + reason
  2. For custom errors inheriting from Error: Override the Parameters property to include your custom fields in ProblemDetails:

    public sealed record OrderLimitError : Error
    {
    public override string Code => "ORDER_LIMIT_EXCEEDED";
    public override int StatusCode => 422;
    public int MaxItems { get; init; }
    public int RequestedItems { get; init; }
    public override IReadOnlyDictionary<string, object>? Parameters =>
    new Dictionary<string, object>
    {
    ["maxItems"] = MaxItems,
    ["requestedItems"] = RequestedItems
    };
    }
  3. For errors implementing IError directly: The ProblemDetails converter uses the Code and StatusCode properties. Additional fields must be added via the Parameters dictionary on the Error base class, or by implementing a custom IProblemDetailsWriter.


Profiling shows heap allocations where you expect zero-allocation behavior.

  1. Boxing. Casting Result<T, E> to object or IResultBase boxes the struct:

    // Allocates: boxing
    object obj = result;
    IResultBase baseResult = result;
    // No allocation: use concrete type
    var result = GetUser(id);
  2. Lambda closures. Lambdas that capture local variables allocate a closure object:

    // Allocates: captures 'id'
    var id = GetId();
    result.Map(x => ProcessWith(x, id));
    // No allocation: static lambda or method group
    result.Map(static x => x.ToString());
    result.Map(Process);
  3. LINQ in hot paths. Methods like results.GetSuccesses() use LINQ internally. For hot paths, use manual iteration:

    // LINQ (allocates enumerator)
    var users = results.GetSuccesses().ToList();
    // Manual (no LINQ allocation)
    var users = new List<User>(results.Length);
    foreach (var r in results)
    {
    if (r.TryGetValue(out var user))
    users.Add(user);
    }
  4. Reference-type errors. If your error type is a record class (the default for records), the error itself allocates on the heap. For zero-allocation errors on failure paths, use record struct:

    // Allocates: record class (default)
    public record NotFoundError : Error { ... }
    // Zero-allocation: record struct
    public readonly record struct NotFoundError : IError { ... }

    Note: the built-in errors (NotFoundError, ConflictError, etc.) are record class types because they inherit from the Error abstract record. This is acceptable because failure paths are not the hot path.


IDSeverityCauseFix
PRAG0001WarningUnsafe .Value access without IsSuccess/IsFailure checkAdd guard check, use TryGetValue(), or use Match()

The PRAG0001 analyzer is included in the Pragmatic.Result.Analyzers package. It detects direct access to .Value or .Error without a preceding state check in the same scope. Safe patterns that suppress the warning:

// Guard with if
if (result.IsSuccess)
var value = result.Value;
// Guard with ternary
var value = result.IsSuccess ? result.Value : default;
// Guard-return pattern
if (!result.IsSuccess) return;
var value = result.Value;
// Match (no .Value access needed)
var value = result.Match(v => v, e => default);

Yes. Wrap the Result in a Task:

public async Task<Result<User, NotFoundError>> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null) return NotFoundError.Create("User", id);
return user;
}

Async extension methods (MapAsync, BindAsync, TapAsync) work on Task<Result<T, E>> for pipeline composition.

How do I create a custom [GenerateResult] type?

Section titled “How do I create a custom [GenerateResult] type?”

Define an interface with the [GenerateResult] attribute listing your error types:

[GenerateResult(typeof(NotFoundError), typeof(ValidationError), typeof(ConflictError))]
public interface IUserOperationResult { }

The SG generates UserOperationResult<TValue> with implicit conversions, Match(), and all standard Result features.

AggregateError.Errors gives you the list of individual errors:

if (result.TryGetError(out var aggregate))
{
foreach (var error in aggregate.Errors)
{
logger.LogWarning("Sub-error: {Code}", error.Code);
}
}

Is Result compatible with FluentValidation?

Section titled “Is Result compatible with FluentValidation?”

Yes. Map FluentValidation results into Pragmatic errors:

var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
return new ValidationError(validation.Errors.First().ErrorMessage);

For deeper integration, see Pragmatic.Validation which generates validators automatically.

Can I use Result in library code without ASP.NET Core?

Section titled “Can I use Result in library code without ASP.NET Core?”

Yes. The core Pragmatic.Result package has no ASP.NET Core dependency. It targets netstandard2.0 and works in any .NET project. The ASP.NET Core integration (Pragmatic.Result.AspNetCore) is a separate package.

// Assert success
var result = service.GetUser(42);
result.IsSuccess.Should().BeTrue();
result.Value.Id.Should().Be(42);
// Assert failure
var result = service.GetUser(999);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("NOT_FOUND");
// For VoidResult
var result = service.ValidateInput(input);
result.IsSuccess.Should().BeTrue();
// Mock a Result-returning method
mock.Setup(s => s.GetUserAsync(42))
.ReturnsAsync(Result<User, NotFoundError>.Success(testUser));
mock.Setup(s => s.GetUserAsync(999))
.ReturnsAsync(NotFoundError.Create("User", 999));