Skip to content

Common Mistakes

These are the most common issues developers encounter when using Pragmatic.Result. Each section shows the wrong approach, the correct approach, and explains why.


1. Throwing Exceptions Instead of Returning Errors

Section titled “1. Throwing Exceptions Instead of Returning Errors”

Wrong:

public Result<User, NotFoundError> GetUser(int id)
{
var user = _repository.FindById(id);
if (user is null)
throw new KeyNotFoundException($"User {id} not found");
if (!user.IsActive)
throw new InvalidOperationException("User is inactive");
return user;
}

Runtime result: The exceptions bypass the Result pipeline entirely. Callers expecting NotFoundError get a raw 500 Internal Server Error instead. The typed error in the method signature is a lie.

Right:

public Result<User, NotFoundError> GetUser(int id)
{
var user = _repository.FindById(id);
if (user is null)
return NotFoundError.Create("User", id);
return user;
}

Why: The entire point of the Result pattern is to make error paths explicit in the type system. Throwing exceptions instead of returning typed errors defeats this purpose. Exceptions should be reserved for truly unexpected failures (out of memory, database connection lost), not for business logic outcomes like “entity not found.”

If you need to represent multiple failure modes, use a multi-error Result:

public Result<User, NotFoundError, BusinessRuleError> GetUser(int id)
{
var user = _repository.FindById(id);
if (user is null)
return NotFoundError.Create("User", id);
if (!user.IsActive)
return BusinessRuleError.Inactive("User");
return user;
}

2. Accessing Value Without Checking IsSuccess

Section titled “2. Accessing Value Without Checking IsSuccess”

Wrong:

var result = await userService.GetUserAsync(id);
Console.WriteLine(result.Value.Name); // InvalidOperationException if failure!

Runtime result: If the operation failed, accessing .Value throws InvalidOperationException: "Cannot access Value when result is failure." The Roslyn analyzer PRAG0001 warns about this at build time.

Right:

// Option A: Match (safest, exhaustive)
var name = result.Match(
user => user.Name,
error => "Unknown");
// Option B: TryGet pattern
if (result.TryGetValue(out var user))
Console.WriteLine(user.Name);
// Option C: Guard check
if (result.IsSuccess)
Console.WriteLine(result.Value.Name);
// Option D: Guard-return pattern
if (result.IsFailure)
return BadRequest(result.Error);
var user = result.Value; // Safe after early return

Why: Result<T, E> is a discriminated union. Only one of the two fields holds a meaningful value at any time. Accessing the wrong field is a programming error, similar to dereferencing null. The PRAG0001 analyzer catches this statically. Use Match() for exhaustive handling, or TryGetValue() for the TryParse pattern.


3. Using Result for Programming Errors (Use Ensure Instead)

Section titled “3. Using Result for Programming Errors (Use Ensure Instead)”

Wrong:

public Result<Order, BadRequestError> CreateOrder(OrderRequest? request)
{
if (request is null)
return BadRequestError.Create("Request cannot be null");
if (request.Items is null)
return BadRequestError.Create("Items cannot be null");
// ...
}

Design problem: Null arguments are programming errors, not expected business outcomes. Returning a Result for null checks hides bugs that should fail fast with a clear exception and stack trace.

Right:

using Pragmatic.Ensure;
public Result<Order, BusinessRuleError> CreateOrder(OrderRequest request)
{
Ensure.ThrowIfNull(request); // Fail fast for programming errors
if (request.Items.Count == 0)
return BusinessRuleError.Create("EmptyOrder", "Order must have at least one item");
// ...
}

Why: There are two categories of failures:

CategoryMechanismExample
Programming errorsEnsure.ThrowIfNull, ArgumentExceptionNull argument, invalid state, contract violation
Business outcomesResult<T, E>Entity not found, insufficient funds, permission denied

Programming errors should crash loudly so the developer fixes them. Business outcomes are expected and should be returned as typed errors. Do not use Result to make programming errors “recoverable” — they are not.


4. Forgetting That Implicit Conversions Exist

Section titled “4. Forgetting That Implicit Conversions Exist”

Wrong:

public Result<User, NotFoundError> GetUser(int id)
{
var user = _repository.FindById(id);
if (user is null)
return Result<User, NotFoundError>.Failure(NotFoundError.Create("User", id));
return Result<User, NotFoundError>.Success(user);
}

Design problem: This is correct but unnecessarily verbose. Every factory call repeats the full generic type.

Right:

public Result<User, NotFoundError> GetUser(int id)
{
var user = _repository.FindById(id);
if (user is null)
return NotFoundError.Create("User", id); // Implicit: TError -> Failure
return user; // Implicit: TValue -> Success
}

Why: Result<TValue, TError> has implicit conversions from both TValue and TError. The compiler infers the correct factory call. Use the explicit factories (Result<T,E>.Success(), Result<T,E>.Failure()) only when the compiler cannot infer the type, such as in ternary expressions or when the value and error types overlap.


Wrong:

return NotFoundError.Create("Entity", null);
return BadRequestError.Create("Error occurred");
return BusinessRuleError.Create("Validation failed");

Runtime result: The ProblemDetails response contains no actionable information. The API consumer cannot tell what went wrong:

{ "code": "NOT_FOUND", "entityType": "Entity", "entityId": null }

Right:

return NotFoundError.Create("User", userId.ToString());
return BadRequestError.MissingHeader("X-Api-Key");
return BusinessRuleError.InsufficientFunds(requested: 100m, available: 50m);

Why: Error messages exist for two audiences: API consumers (who read the ProblemDetails response) and developers (who debug from logs). Both need specifics. Use the semantic factory methods on built-in errors — they enforce meaningful context. For custom errors, always include the relevant domain data:

public record OrderLimitError(int MaxItems, int RequestedItems) : IError
{
public string Code => "ORDER_LIMIT_EXCEEDED";
public int StatusCode => 422;
}

Wrong:

public Result<bool, ValidationError> ValidateInput(InputDto input)
{
if (string.IsNullOrWhiteSpace(input.Name))
return new ValidationError("Name is required");
return true; // What does "true" mean here?
}

Design problem: Using Result<bool, E> for an operation that returns no meaningful value. The bool is always true on success — it carries no information.

Right:

public VoidResult<ValidationError> ValidateInput(InputDto input)
{
if (string.IsNullOrWhiteSpace(input.Name))
return new ValidationError("Name is required");
return VoidResult<ValidationError>.Success();
}

Why: VoidResult<TError> communicates intent: “this operation succeeds or fails, with no return value.” Using Result<bool, E> or Result<Unit, E> adds a meaningless success value. The HTTP integration also handles VoidResult correctly: success maps to 204 No Content, not 200 OK with true in the body.


7. Not Implementing IError Correctly on Custom Errors

Section titled “7. Not Implementing IError Correctly on Custom Errors”

Wrong:

public class PaymentFailedError
{
public string Reason { get; set; } = "";
}

Compile result: This type cannot be used as a TError parameter. The constraint where TError : IError rejects it.

Right:

// Minimal: implement IError directly
public record PaymentFailedError(string Reason) : IError
{
public string Code => "PAYMENT_FAILED";
public int StatusCode => 422;
}
// Rich: inherit from Error for localization + retry support
public sealed record PaymentFailedError : Error
{
public override string Code => "PAYMENT_FAILED";
public override int StatusCode => 422;
public override string Title => "Payment Failed";
public string? Reason { get; init; }
}

Why: All error types used with Result must implement IError. This ensures every error has at least a Code property for identification and serialization. If you need HTTP status codes (for ASP.NET Core integration), add a StatusCode property. If you need localization, inherit from Error which provides MessageKey, Parameters, and IsTransient.


8. Catching Exceptions That Should Be Result Errors

Section titled “8. Catching Exceptions That Should Be Result Errors”

Wrong:

public async Task<Result<Order, NotFoundError>> GetOrderAsync(int id)
{
try
{
var order = await _externalService.FetchOrderAsync(id);
return order;
}
catch (HttpRequestException ex)
{
// Silently converts to NotFoundError, losing context
return NotFoundError.Create("Order", id.ToString());
}
}

Runtime result: An HTTP timeout or 503 from the external service is reported as “not found” — a completely misleading error. The caller has no way to distinguish a genuine 404 from a service outage.

Right:

public async Task<Result<Order, NotFoundError, DependencyError>> GetOrderAsync(int id)
{
try
{
var order = await _externalService.FetchOrderAsync(id);
if (order is null)
return NotFoundError.Create("Order", id.ToString());
return order;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return NotFoundError.Create("Order", id.ToString());
}
catch (HttpRequestException ex)
{
return DependencyError.Unavailable("OrderService");
}
catch (TaskCanceledException)
{
return DependencyError.Timeout("OrderService");
}
}

Or use the Result.TryAsync factory:

var result = await Result.TryAsync(
async ct => await _externalService.FetchOrderAsync(id, ct),
ex => ex switch
{
HttpRequestException { StatusCode: HttpStatusCode.NotFound }
=> (IError)NotFoundError.Create("Order", id.ToString()),
TaskCanceledException
=> DependencyError.Timeout("OrderService"),
_ => DependencyError.Unavailable("OrderService")
},
cancellationToken);

Why: Different exception types represent different failure modes. Collapsing them into a single error type loses information that the caller needs to make decisions (retry? show 404? show 503?). Use multi-error Result types to express the distinct failure modes, and catch exceptions precisely.


9. Using Result<T> Where Maybe<T> Is Correct

Section titled “9. Using Result<T> Where Maybe<T> Is Correct”

Wrong:

public Result<CachedUser, NotFoundError> GetCachedUser(string key)
{
if (_cache.TryGetValue(key, out CachedUser? user))
return user;
return NotFoundError.Create("CachedUser", key);
}

Design problem: A cache miss is not an error. It is a normal, expected outcome. Returning NotFoundError for a cache miss conflates “absence is normal” with “absence is an error.” The caller might log warnings or return 404 for what is simply a cold cache.

Right:

public Maybe<CachedUser> GetCachedUser(string key)
{
if (_cache.TryGetValue(key, out CachedUser? user))
return Maybe<CachedUser>.Some(user);
return Maybe<CachedUser>.None();
}
// Caller
var user = GetCachedUser(key).GetValueOrDefault(LoadFromDatabase(key));

Why: Maybe<T> signals “this value might not exist, and that is fine.” Result<T, NotFoundError> signals “this value should exist, and its absence is a meaningful error.” Use the type that matches the semantic:

ScenarioTypeReason
Cache lookupMaybe<T>Miss is normal
Database entity by IDResult<T, NotFoundError>404 is meaningful
Optional configMaybe<T>Default applies
User profile lookupResult<T, NotFoundError>Missing profile is a bug

10. Not Using [HttpStatus] on Custom Errors for Endpoints

Section titled “10. Not Using [HttpStatus] on Custom Errors for Endpoints”

Wrong:

public record PaymentRequiredError(string Reason) : IError
{
public string Code => "PAYMENT_REQUIRED";
// No StatusCode -- defaults to 422 in endpoint error mapping
}

Runtime result: When used with Pragmatic.Endpoints, this error is mapped to 422 Unprocessable Entity (the default for unknown IError types). The API consumer expects 402 Payment Required based on the error semantics but gets 422.

Right:

public record PaymentRequiredError(string Reason) : IError
{
public string Code => "PAYMENT_REQUIRED";
public int StatusCode => 402;
}

Or, when inheriting from Error:

public sealed record PaymentRequiredError : Error
{
public override string Code => "PAYMENT_REQUIRED";
public override int StatusCode => 402;
public override string Title => "Payment Required";
public string? Reason { get; init; }
}

Why: The ASP.NET Core integration reads StatusCode from the error to determine the HTTP response status. Without it, the framework cannot map your custom error to the correct HTTP status. Built-in errors (NotFoundError, ConflictError, etc.) already have correct status codes. For custom errors, always specify StatusCode explicitly.


11. Awaiting Each Step Separately in Async Pipelines

Section titled “11. Awaiting Each Step Separately in Async Pipelines”

Wrong:

var r1 = await GetUserAsync(id);
if (r1.IsFailure) return r1.Error;
var r2 = await EnrichUserAsync(r1.Value);
if (r2.IsFailure) return r2.Error;
var r3 = await ValidateAccessAsync(r2.Value);
if (r3.IsFailure) return r3.Error;
return r3.Value;

Design problem: This is correct but verbose. Each step repeats the same check-and-propagate pattern. With more steps, the noise-to-signal ratio worsens.

Right:

var result = await GetUserAsync(id)
.BindAsync(user => EnrichUserAsync(user))
.BindAsync(user => ValidateAccessAsync(user));
return result;

Why: The async extension methods (BindAsync, MapAsync, TapAsync) implement the railway pattern: if any step fails, subsequent steps are skipped and the error propagates. This eliminates the repetitive guard-and-return boilerplate while preserving the same behavior. Each BindAsync call chains a Result-returning operation; each MapAsync transforms the success value.


Wrong:

object resultObj = GetUser(id); // Boxes the struct onto the heap
IResultBase resultBase = GetUser(id); // Also boxes

Runtime result: Result<T, E> is a readonly struct. Casting it to object or an interface (IResultBase) boxes it onto the heap, negating the zero-allocation design.

Right:

var result = GetUser(id); // Stays on the stack
// If you need runtime type detection (e.g., middleware), use IResultBase
// but only at the boundary, not in hot loops
if (result is IResultBase baseResult && baseResult.IsFailure)
{
// This path is for middleware, not business logic
}

Why: The entire performance model of Pragmatic.Result depends on struct semantics. In business logic, always work with the concrete Result<T, E> type. The IResultBase interface exists for ASP.NET Core filters and middleware that need to handle Result types generically at the HTTP boundary — not for application code on hot paths.


MistakeSymptom
Throwing exceptions500 instead of typed HTTP status
Accessing Value without checkInvalidOperationException at runtime, PRAG0001 warning
Result for programming errorsHides bugs, use Ensure.ThrowIfNull instead
Ignoring implicit conversionsVerbose code with unnecessary factory calls
Empty error messagesUnhelpful ProblemDetails for API consumers
Result<bool, E> for void opsMeaningless success value; use VoidResult<E>
Missing IError implementationCompile error on generic constraint
Catching exceptions incorrectlyLosing context, misleading error types
Result where Maybe fitsTreating normal absence as an error
Missing StatusCode on custom errorsDefaults to 422 in endpoint mapping
Separate awaits in pipelineVerbose; use BindAsync/MapAsync chain
Boxing Result structsHeap allocation, negates zero-allocation design