如何优雅处理方法返回不同错误与成功数据的场景?
Great question! Dealing with methods that need to return different success/error data types is a common pain point, and your two existing approaches do have the drawbacks you mentioned—forced casting feels clunky, and stuffing all possible fields into a single base class violates the single responsibility principle. Let's look at some cleaner alternatives that balance type safety, maintainability, and readability:
1. Use a Discriminated Union (via Libraries or Custom Implementation)
C# doesn't have native discriminated unions, but libraries like OneOf fill this gap perfectly. This approach lets you explicitly define all possible return types, no casting required, and each result type only holds the data it needs.
Example with OneOf:
First, install the OneOf NuGet package. Then structure your code like this:
// Define distinct result types (no shared base class needed) public record ValidUserResult(IAuthenticableUser User, string Token); public record UserExistsResult; public record InvalidPasswordResult; public record InvalidEmailResult; // Update your authentication method public async Task<OneOf<ValidUserResult, UserExistsResult, InvalidPasswordResult, InvalidEmailResult>> AuthenticateAsync(string email, string password) { // Validate email format first if (!IsValidEmail(email)) return new InvalidEmailResult(); var existingUser = await _dbContext.Users.SingleOrDefaultAsync(x => x.Email == email); if (existingUser == null) { // Create new user logic var newUser = CreateUser(email, password); await _dbContext.Users.AddAsync(newUser); await _dbContext.SaveChangesAsync(); return new ValidUserResult(newUser, GenerateAuthToken(newUser)); } // Verify password if (!PasswordHasher.Verify(password, existingUser.PasswordHash)) return new InvalidPasswordResult(); return new ValidUserResult(existingUser, GenerateAuthToken(existingUser)); } // Handle the result with type-safe matching var authResult = await _authService.AuthenticateAsync(userEmail, userPassword); authResult.Match( valid => { // Use valid.User and valid.Token directly Console.WriteLine($"Authenticated as {valid.User.Email}"); }, exists => { Console.WriteLine("This email is already registered."); }, invalidPass => { Console.WriteLine("Incorrect password."); }, invalidEmail => { Console.WriteLine("Please enter a valid email address."); } );
Why this works:
- Type safety: The compiler ensures you handle every possible result type (no missing cases!).
- Clean separation: Each result class only contains the data relevant to its scenario.
- Readable: The
Matchmethod makes it crystal clear how each outcome is handled.
If you don't want to use a third-party library, you can build a simple custom discriminated union (though it's less scalable for adding new result types later).
2. Generic Result Pattern with Explicit Error Types
If you prefer a no-dependency approach, a generic Result class that separates success data from error details is a solid choice.
Example Implementation:
// Generic result container public class Result<T> { public bool IsSuccess { get; } public T SuccessData { get; } public ValidationStatus ErrorStatus { get; } public string ErrorMessage { get; } // Private constructors enforce using factory methods private Result(T data) { IsSuccess = true; SuccessData = data; ErrorStatus = default; ErrorMessage = null; } private Result(ValidationStatus errorStatus, string message) { IsSuccess = false; SuccessData = default; ErrorStatus = errorStatus; ErrorMessage = message; } public static Result<T> Success(T data) => new Result<T>(data); public static Result<T> Failure(ValidationStatus errorStatus, string message) => new Result<T>(errorStatus, message); } // DTO for success data public record AuthSuccessData(IAuthenticableUser User, string Token); // Updated authentication method public async Task<Result<AuthSuccessData>> AuthenticateAsync(string email, string password) { if (!IsValidEmail(email)) return Result<AuthSuccessData>.Failure(ValidationStatus.InvalidEmail, "Invalid email format."); var existingUser = await _dbContext.Users.SingleOrDefaultAsync(x => x.Email == email); if (existingUser == null) { var newUser = CreateUser(email, password); await _dbContext.Users.AddAsync(newUser); await _dbContext.SaveChangesAsync(); return Result<AuthSuccessData>.Success(new AuthSuccessData(newUser, GenerateAuthToken(newUser))); } if (!PasswordHasher.Verify(password, existingUser.PasswordHash)) return Result<AuthSuccessData>.Failure(ValidationStatus.InvalidPassword, "Wrong password."); return Result<AuthSuccessData>.Success(new AuthSuccessData(existingUser, GenerateAuthToken(existingUser))); } // Handling the result var authResult = await _authService.AuthenticateAsync(userEmail, userPassword); if (authResult.IsSuccess) { var user = authResult.SuccessData.User; var token = authResult.SuccessData.Token; // Proceed with authenticated logic } else { switch (authResult.ErrorStatus) { case ValidationStatus.InvalidEmail: // Handle invalid email break; case ValidationStatus.InvalidPassword: // Handle wrong password break; case ValidationStatus.UserExists: // Handle existing user break; } }
Why this works:
- No dependencies: No external libraries required—just a simple generic class.
- Clear separation: Success data and error details are kept distinct, so you don't have unused fields cluttering up your objects.
- Easy to extend: Add new error statuses to the
ValidationStatusenum without modifying theResultclass.
3. Visitor Pattern (For Inheritance-Based Systems)
If you want to stick with a base class inheritance model but avoid forced casting, the Visitor Pattern is a classic object-oriented solution.
Example Implementation:
// Visitor interface defining handlers for each result type public interface IUserResultVisitor<TOutput> { TOutput Visit(ValidUserResult result); TOutput Visit(UserExistsResult result); TOutput Visit(InvalidPasswordResult result); } // Base result class with Accept method public abstract class UserServiceResult { public abstract TOutput Accept<TOutput>(IUserResultVisitor<TOutput> visitor); } // Concrete result classes public class ValidUserResult : UserServiceResult { public IAuthenticableUser User { get; } public string Token { get; } public ValidUserResult(IAuthenticableUser user, string token) { User = user; Token = token; } public override TOutput Accept<TOutput>(IUserResultVisitor<TOutput> visitor) { return visitor.Visit(this); } } public class UserExistsResult : UserServiceResult { public override TOutput Accept<TOutput>(IUserResultVisitor<TOutput> visitor) { return visitor.Visit(this); } } // Example visitor to handle authentication results public class AuthResultHandler : IUserResultVisitor<string> { public string Visit(ValidUserResult result) { return $"Authenticated: {result.User.Email} (Token: {result.Token})"; } public string Visit(UserExistsResult result) { return "Error: User already exists."; } public string Visit(InvalidPasswordResult result) { return "Error: Incorrect password."; } } // Your original authentication method (returns base class) public async Task<UserServiceResult> AuthenticateAsync(string email, string password) { // Same logic as before, returning the appropriate result instance } // Handling the result with the visitor var authResult = await _authService.AuthenticateAsync(userEmail, userPassword); var handler = new AuthResultHandler(); var resultMessage = authResult.Accept(handler); Console.WriteLine(resultMessage);
Why this works:
- No casting: The visitor pattern lets you handle each result type explicitly without downcasting.
- Open/Closed Principle: Add new result types by creating a new class and updating the visitor interface—no changes to existing result classes needed.
The downside is that it adds more boilerplate code compared to the other options, so it's best suited for larger systems where you need to enforce OOP principles strictly.
Final Recommendations
- Go with OneOf if you want the most type-safe, clean solution—It's my go-to for these scenarios.
- Use the generic Result pattern if you want a lightweight, dependency-free approach.
- Try the Visitor Pattern only if you're committed to an inheritance-based design and need to avoid casting.
内容的提问来源于stack exchange,提问作者Notbad

