Understanding IResult and IResult T - ulfbou/Zentient.Results GitHub Wiki
At the heart of Zentient.Results are the IResult and IResult<T> interfaces, along with their concrete Result and Result<T> struct implementations. These are the fundamental building blocks that enable explicit, predictable, and robust outcome handling in your .NET applications.
This page will help you grasp what these types are, why they exist, and how they function.
Traditionally, C# methods that perform an action might return void (implying success) or a direct value T (implying success), and communicate failures by throwing exceptions. While exceptions are vital for truly exceptional and unrecoverable errors, they can lead to tangled control flow when used for expected business outcomes (e.g., "user not found," "invalid input").
Zentient.Results introduces IResult and IResult<T> as explicit return types that declare: "This operation might succeed, or it might fail, and here's all the context if it does."
This approach provides:
- Predictability: The method signature immediately tells you that you need to handle both success and failure.
- Clarity: It's always clear whether an operation completed successfully or with an issue.
- Structured Information: Failures carry rich, categorized error details, not just a stack trace.
The generic IResult<T> interface (and its concrete implementation, Result<T>) is used for operations that, upon success, produce a specific value of type T.
Purpose: Use IResult<T> when your method computes or retrieves something concrete.
Example Use Cases:
IResult<User> GetUserById(Guid id)IResult<Order> CreateOrder(OrderRequest request)IResult<decimal> CalculateDiscount(decimal amount)
| Property | Type | Description |
|---|---|---|
IsSuccess |
bool |
true if the operation succeeded; false otherwise. |
IsFailure |
bool |
true if the operation failed; false otherwise. (Convenient inverse of IsSuccess) |
Value |
T? |
The successful result value. Only access if IsSuccess is true. Will be default(T) if IsFailure is true (or if explicitly passed to Failure methods). |
Errors |
IReadOnlyList<ErrorInfo> |
A collection of detailed ErrorInfo objects, present only if IsFailure is true. Can contain multiple errors (e.g., from validation). |
Error |
string? |
A convenience property that returns the Message of the first ErrorInfo in the Errors collection if available; otherwise null. |
Messages |
IReadOnlyList<string> |
A collection of non-error informational messages associated with the result. Useful for warnings or additional context. |
Status |
IResultStatus |
A high-level status object (e.g., ResultStatuses.Success, ResultStatuses.NotFound), often mapping to HTTP status codes. Provides an int Code and string Description. |
Result<T> provides numerous static factory methods to create instances, ensuring correct state initialization:
-
Success:
return Result<User>.Success(newUser); // Common success with value return Result<Order>.Created(newOrder); // Success indicating resource creation (201)
-
Failure:
// Generic failure: return Result<Product>.Failure( default, // Often default(T) for failure, but can pass a partial value if sensible new ErrorInfo(ErrorCategory.Validation, "InvalidSku", "SKU format is incorrect."), ResultStatuses.BadRequest ); // Convenience methods for common failures: return Result<User>.NotFound( new ErrorInfo(ErrorCategory.NotFound, "UserNotFound", $"User with ID {userId} was not found.") ); return Result<string>.Unauthorized( new ErrorInfo(ErrorCategory.Authentication, "TokenExpired", "Authentication token is expired.") ); return Result<Customer>.Conflict( new ErrorInfo(ErrorCategory.Conflict, "EmailTaken", "Email already registered.", new { emailAddress }) );
The non-generic IResult interface (and its concrete implementation, Result) is used for operations whose primary outcome is a state change or an action, rather than producing a specific return value. Think of void methods in traditional C#.
Purpose: Use IResult for "command" methods that might succeed or fail.
Example Use Cases:
IResult DeleteProduct(string productId)IResult SendEmail(EmailMessage message)IResult UpdateOrderStatus(Guid orderId, OrderStatus newStatus)
IResult shares most properties with IResult<T>, excluding the Value property.
| Property | Type | Description |
|---|---|---|
IsSuccess |
bool |
true if the operation succeeded; false otherwise. |
IsFailure |
bool |
true if the operation failed; false otherwise. |
Errors |
IReadOnlyList<ErrorInfo> |
A collection of detailed ErrorInfo objects. |
Error |
string? |
The Message of the first ErrorInfo if available. |
Messages |
IReadOnlyList<string> |
A collection of non-error informational messages. |
Status |
IResultStatus |
A high-level status object (e.g., ResultStatuses.Success, ResultStatuses.Forbidden). |
Result also provides static factory methods:
-
Success:
return Result.Success(); // Simple success (often maps to HTTP 200 OK) return Result.Success(ResultStatuses.NoContent); // Success indicating no content (HTTP 204)
-
Failure:
// Generic failure: return Result.Failure( new ErrorInfo(ErrorCategory.Network, "ConnectionLost", "Failed to connect to external service."), ResultStatuses.InternalServerError ); // Convenience methods for common failures: return Result.Validation( // Specifically for validation errors (often maps to HTTP 400) new ErrorInfo(ErrorCategory.Validation, "InvalidInput", "Input field 'x' is required.") ); return Result.Forbidden( new ErrorInfo(ErrorCategory.Authorization, "AccessDenied", "User does not have permission to delete this resource.") );
Both Result and Result<T> are implemented as readonly struct in C#. This is a deliberate design choice with several benefits:
-
Immutability: Once a
ResultorResult<T>instance is created, its internal state (whether it's a success or failure, its value, errors, and status) cannot be changed. This makes reasoning about your code easier, reduces potential bugs from unexpected side effects, and is crucial for thread-safe operations. -
Performance:
structtypes are typically allocated on the stack (or inline within other objects) rather than the heap. This reduces pressure on the garbage collector, leading to better performance, especially in high-throughput applications where many result objects might be created and discarded. -
Value Semantics: They behave like simple values (e.g., an
intor abool). When you pass aResultaround, you are passing a copy of its immutable state.
The Status property (of type IResultStatus) provides a high-level classification of the outcome. It's primarily intended to align with well-understood codes, such as HTTP status codes, but can also be customized for domain-specific contexts.
Key Distinction: Status vs. Errors
-
Status(What happened?): A general categorization (e.g.,404 Not Found,200 OK,400 Bad Request). It tells you the type of overall outcome. -
Errors(Why it happened?): Provides granular, detailedErrorInfoobjects that explain the specific reasons for a failure. AnIResultStatusofBadRequestmight correspond to multipleErrorInfoinstances detailing which input fields were invalid.
The Errors property holds a collection of ErrorInfo objects.
-
Multiple Errors: It's a list (
IReadOnlyList<ErrorInfo>) because an operation can fail for multiple reasons simultaneously. For example, a validation process might identify several invalid input fields. -
Structured Details: Each
ErrorInfoprovides aCategory,Code,Message, and optionalData, offering rich context for diagnostics, logging, and user feedback.
| Feature | IResult<T> |
IResult |
|---|---|---|
| Primary Use | Operations that produce a specific output value. | Operations that primarily cause a side effect/state change. |
| Success Value | Contains a Value of type T. |
Does not contain a value. |
| Example |
GetUserById(), CalculateTax()
|
DeleteUser(), SendEmail()
|
| Factory Methods |
Success(T value), Failure(T? value, ErrorInfo...)
|
Success(), Failure(ErrorInfo...)
|
| Functional Ops | Supports Map, Bind (to transform T). |
Supports OnSuccess, OnFailure (for side effects). |
When to Choose Which:
-
Choose
IResult<T>when your method's main purpose is to return data that the caller needs to proceed. -
Choose
IResultwhen your method's main purpose is to perform an action, and the caller only needs to know if the action succeeded or failed.
using Zentient.Results;
using System;
using System.Collections.Generic;
public class UserProfileService
{
private static Dictionary<Guid, UserProfile> _userProfiles = new Dictionary<Guid, UserProfile>();
// IResult<T>: Get a user profile (produces a value)
public IResult<UserProfile> GetProfile(Guid userId)
{
if (!_userProfiles.TryGetValue(userId, out var profile))
{
return Result<UserProfile>.NotFound(
new ErrorInfo(ErrorCategory.NotFound, "UserProfileNotFound", $"Profile for user {userId} not found.")
);
}
return Result<UserProfile>.Success(profile);
}
// IResult: Update a user profile (performs an action, no value returned)
public IResult UpdateProfile(Guid userId, string newEmail)
{
if (string.IsNullOrWhiteSpace(newEmail) || !newEmail.Contains("@"))
{
return Result.BadRequest(
new ErrorInfo(ErrorCategory.Validation, "InvalidEmail", "Invalid email format.")
);
}
if (!_userProfiles.ContainsKey(userId))
{
return Result.NotFound(
new ErrorInfo(ErrorCategory.NotFound, "UserProfileNotFound", $"Profile for user {userId} not found.")
);
}
// Simulate update
_userProfiles[userId].Email = newEmail;
Console.WriteLine($"Profile for {userId} updated with new email: {newEmail}");
return Result.Success();
}
// Helper for initial data
public void AddProfile(UserProfile profile)
{
_userProfiles[profile.UserId] = profile;
}
}
public class UserProfile
{
public Guid UserId { get; set; }
public string Username { get; set; }
public string Email { get; set; }
}
public class Application
{
public static void Main(string[] args)
{
var service = new UserProfileService();
var userAId = Guid.NewGuid();
var userBId = Guid.NewGuid();
service.AddProfile(new UserProfile { UserId = userAId, Username = "Alice", Email = "[email protected]" });
// GetProfile - Success
var profileResult = service.GetProfile(userAId);
if (profileResult.IsSuccess)
{
Console.WriteLine($"Found profile for: {profileResult.Value.Username} ({profileResult.Value.Email})");
}
// GetProfile - Failure (Not Found)
var notFoundResult = service.GetProfile(userBId);
if (notFoundResult.IsFailure)
{
Console.WriteLine($"Error getting profile ({notFoundResult.Status.Code}): {notFoundResult.Error}");
}
Console.WriteLine("\n--- Updating Profiles ---");
// UpdateProfile - Success
var updateSuccessResult = service.UpdateProfile(userAId, "[email protected]");
if (updateSuccessResult.IsSuccess)
{
Console.WriteLine("Profile updated successfully!");
}
// UpdateProfile - Failure (Invalid Email)
var updateInvalidEmailResult = service.UpdateProfile(userAId, "invalid-email");
if (updateInvalidEmailResult.IsFailure)
{
Console.WriteLine($"Error updating profile ({updateInvalidEmailResult.Status.Code}): {updateInvalidEmailResult.Error}");
}
// UpdateProfile - Failure (Profile Not Found)
var updateNotFoundResult = service.UpdateProfile(userBId, "[email protected]");
if (updateNotFoundResult.IsFailure)
{
Console.WriteLine($"Error updating profile ({updateNotFoundResult.Status.Code}): {updateNotFoundResult.Error}");
}
}
}Now that you understand the core IResult and IResult<T> types, explore these related topics:
- Basic Usage Patterns: More practical examples of creating and consuming results.
-
Structured Error Handling with
ErrorInfoandErrorCategory: A deeper dive into how errors are represented. -
Chaining and Composing Operations: Learn how to build powerful workflows using
Map,Bind, and other fluent methods. -
Managing Operation Status with
IResultStatusandResultStatuses: Understand how status codes work.