先说结论:联合类型不会让异常处理消失,也不会自动淘汰现有的 Result<T>。但它可能迫使很多 .NET 项目重新思考:哪些失败是业务结果,哪些失败才是真正的异常。
C# 15 在 .NET 11 Preview 7 中提供了联合类型预览。它允许开发者明确声明:一个值只能是给定类型集合中的一种。
官方示例大致如下:
public record Cat(string Name);
public record Dog(string Name);
public record Bird(string Name);
public union Pet(Cat, Dog, Bird);
Pet 只能保存 Cat、Dog 或 Bird。由于编译器知道全部候选类型,对非空 Pet 进行模式匹配时,可以检查分支是否完整:
Pet pet = new Dog("Rex");
string name = pet switch
{
Cat cat => cat.Name,
Dog dog => dog.Name,
Bird bird => bird.Name
};
这不只是少写一个 default 的问题。它让“一个操作可能产生哪些结果”从文档和约定进入类型系统。
那么问题来了:如果 C# 可以直接表达“成功、参数错误、库存不足、订单不存在”,我们现在常用的异常、状态码和 Result<T>,是不是都该重新设计?
一、今天的C#项目通常怎样表达失败?
假设我们正在实现一个订单取消操作。它至少可能产生以下结果:
- 取消成功;
- 订单不存在;
- 当前状态不允许取消;
- 数据库连接失败。
很多项目会直接使用异常:
public async Task CancelOrderAsync(long orderId)
{
var order = await repository.FindAsync(orderId);
if (order is null)
throw new OrderNotFoundException(orderId);
if (!order.CanCancel())
throw new InvalidOrderStatusException(order.Status);
order.Cancel();
await repository.SaveChangesAsync();
}
这种写法很直观,但调用方只看方法签名:
Task CancelOrderAsync(long orderId)
无法知道它会抛出哪些业务异常,只能依赖文档、阅读实现或运行时经验。
于是一些团队引入 Result<T>:
public sealed record Result<T>(
bool IsSuccess,
T? Value,
string? ErrorCode,
string? ErrorMessage);
然后返回:
public Task<Result<CancelOrderResponse>> CancelOrderAsync(long orderId);
调用方终于能看到操作可能失败,却仍然不知道具体有哪些失败类型。ErrorCode 往往是字符串,分支是否完整也无法由编译器检查。
二、联合类型可以怎样表达业务结果?
使用联合类型,可以先把结果建模为一组明确类型:
public record Cancelled(long OrderId, DateTimeOffset CancelledAt);
public record OrderNotFound(long OrderId);
public record CancellationRejected(long OrderId, string CurrentStatus);
public union CancelOrderResult(
Cancelled,
OrderNotFound,
CancellationRejected);
方法签名变成:
public async Task<CancelOrderResult> CancelOrderAsync(long orderId)
{
var order = await repository.FindAsync(orderId);
if (order is null)
return new OrderNotFound(orderId);
if (!order.CanCancel())
return new CancellationRejected(orderId, order.Status);
order.Cancel();
await repository.SaveChangesAsync();
return new Cancelled(order.Id, order.CancelledAt);
}
调用方必须处理这些结果:
CancelOrderResult result = await service.CancelOrderAsync(orderId);
return result switch
{
Cancelled value => Results.Ok(value),
OrderNotFound value => Results.NotFound(value),
CancellationRejected value => Results.Conflict(value)
};
如果以后增加一个 AlreadyRefunded 结果,原有模式匹配就有机会在编译期暴露遗漏,而不是等某个罕见路径在线上出现。
这正是联合类型相对于“布尔值加错误信息”的核心优势:失败不再是成功结果旁边的附属字段,而是与成功同等明确的一种结果。
三、联合类型会取代Result吗?
我认为不会简单取代,因为二者解决问题的层次不完全相同。
Result适合统一处理
统一的 Result<T> 很容易承载:
- 是否成功;
- 通用错误码;
- 展示给用户的消息;
- 链路追踪信息;
- 分页或其他元数据;
Map、Bind等组合操作。
中间件、过滤器和前端客户端也可以按统一协议处理它。
联合类型适合准确表达
联合类型更擅长描述一个具体操作的有限结果集合:
下单结果 = 创建成功 | 库存不足 | 价格已变化 | 会员不可用
这种表达比:
Result<OrderDto>,失败时去看ErrorCode
包含更多编译期信息。
但如果每个业务方法都声明五六个独立结果类型,项目也可能迅速出现大量小类型、转换代码和重复映射。
因此,更现实的做法可能是组合使用:领域层使用联合类型准确描述结果,API 边界再统一映射为 HTTP 状态和标准错误响应。
static IResult ToHttpResult(CancelOrderResult result) => result switch
{
Cancelled value => Results.Ok(value),
OrderNotFound value => Results.NotFound(new ProblemDetails
{
Title = "订单不存在",
Detail = $"未找到订单 {value.OrderId}"
}),
CancellationRejected value => Results.Conflict(new ProblemDetails
{
Title = "当前订单不能取消",
Detail = $"订单状态为 {value.CurrentStatus}"
})
};
这时,“精确的领域结果”和“统一的接口协议”并不冲突。
四、有了联合类型,还需不需要抛异常?
当然需要。
关键不是在“返回结果”和“抛异常”之间二选一,而是先判断失败的性质。
可预期的业务分支更像结果
例如:
- 用户输入不符合规则;
- 订单不存在;
- 库存不足;
- 优惠券已经使用;
- 当前状态不允许执行操作。
这些情况会在正常业务中反复出现,调用方通常知道如何响应。把它们声明为联合类型的一个分支,会让控制流更加明确。
基础设施和程序错误仍然适合异常
例如:
- 数据库连接突然中断;
- 配置缺失;
- 反序列化出现不应存在的数据结构;
- 违反内部不变量;
- 代码访问了无效状态;
- 第三方依赖发生非预期故障。
这些错误通常无法由当前业务调用方真正处理,继续通过异常传播、记录并进入统一故障机制更合适。
如果把所有异常都塞进联合类型,方法签名可能变成:
public union CancelOrderResult(
Cancelled,
OrderNotFound,
CancellationRejected,
DatabaseUnavailable,
Timeout,
SerializationFailed,
UnknownError);
看起来很“完整”,实际上只是把异常系统重新发明了一遍,而且要求每一层重复搬运自己无法处理的故障。
五、它比接口加record好在哪里?
在今天的 C# 中,我们已经可以这样做:
public interface ICancelOrderResult;
public record Cancelled(long OrderId) : ICancelOrderResult;
public record OrderNotFound(long OrderId) : ICancelOrderResult;
public record CancellationRejected(string Status) : ICancelOrderResult;
那联合类型是不是语法糖?
两种模型最明显的区别在于“开放”和“封闭”。
普通接口只表达“这些类型实现了同一个契约”。其他程序集仍可能增加新的实现,编译器不能假定当前列出的类型就是全集。因此,模式匹配通常需要保留兜底分支:
return result switch
{
Cancelled value => Results.Ok(value),
OrderNotFound value => Results.NotFound(value),
CancellationRejected value => Results.Conflict(value),
_ => Results.StatusCode(500)
};
联合类型则直接声明允许的类型集合。对于适合封闭建模的问题,它能让编译器帮助检查完整性。
但接口的开放性并不是缺陷。插件系统、第三方扩展和长期演化的公共抽象,往往就需要允许未来增加新实现。
所以选择标准不应该是“新语法更先进”,而应该是:
- 这个类型集合在业务上是否应该封闭?
- 增加新类型时,是否必须检查所有调用方?
- 未来是否允许第三方扩展?
宠物种类可能天然开放,而一次支付请求的结果集合更适合封闭。模型是否合理,比语法是否简洁重要。
六、联合类型也会带来新的工程成本
新类型系统能力通常会提高表达力,也会增加需要统一的规则。
1. 结果类型会不会泛滥
如果每个方法都声明自己的成功和失败类型,一个大型项目可能出现数百个 record。它们应该放在哪里、能否复用、如何命名,都需要约定。
2. API和序列化工具是否完全支持
联合类型进入 Web API 后,需要确认 OpenAPI 文档、JSON 序列化、客户端生成、模型绑定和 AOT 场景是否符合预期。语言层能够表达,不代表整个工具链已经提供理想体验。
3. ORM是否应该直接认识联合类型
领域模型可能使用联合类型,但 EF Core 实体映射未必应该照搬这种结构。数据库模型、领域模型和传输模型之间可能需要显式转换。
4. 团队会不会滥用模式匹配
如果所有业务逻辑都变成巨大的 switch,只是把分支从 if 换了一种写法。联合类型能保证分支覆盖,不会自动保证业务结构合理。
5. 预览功能是否适合生产项目
C# 15 联合类型目前属于预览能力。语法、限制和工具支持仍可能变化。尝试和评估没有问题,但生产项目是否采用,需要结合版本策略、依赖兼容和维护周期决定。
七、什么场景值得优先尝试?
我认为以下场景比较适合联合类型:
明确的命令执行结果
注册会员 = 注册成功 | 手机号已存在 | 验证码错误
有限状态的协议消息
支付通知 = 支付成功 | 支付关闭 | 退款完成
解析结果
解析结果 = 有效数据 | 格式错误 | 版本不支持
API端点的有限响应
查询订单 = 订单详情 | 未找到 | 无权限
以下场景则要谨慎:
- 类型集合需要第三方扩展;
- 失败种类无法稳定枚举;
- 只是为了省掉一个简单的可空值;
- 底层故障会被逐层原样传递;
- 工具链尚不能正确生成接口文档或客户端。
八、真正的变化可能不是语法,而是方法签名
长期以来,C# 方法签名很容易只描述成功路径:
Task<OrderDto> CreateOrderAsync(CreateOrderRequest request);
至于失败路径,散落在异常、注释、错误码和实现细节里。
联合类型推动的是另一种思路:让方法签名同时描述成功与可预期失败。
Task<CreateOrderResult> CreateOrderAsync(CreateOrderRequest request);
调用方不再只问“成功后返回什么”,还必须面对“这个操作允许以哪些方式失败”。
这可能比少写几行代码更重要。它把错误设计从运行时讨论提前到了 API 设计阶段。
结语
C# 15 联合类型并不是异常处理的终结者,也不是 Result<T> 的自动替代品。
它提供的是一种更精确的建模工具:让有限、可预期的结果进入类型系统,并让编译器协助检查调用方是否处理完整。
我的倾向是:
- 可预期且调用方能够处理的业务失败,优先建模为结果;
- 非预期的基础设施故障和程序错误,继续使用异常;
- 领域内部追求精确表达,API 边界保持统一协议;
- 不为使用新语法而使用,先判断结果集合是否真的封闭。
最后留几个问题,欢迎讨论:
- 你更愿意使用联合类型,还是继续使用统一的
Result<T>? - “订单不存在”应该返回结果,还是抛出业务异常?
- 联合类型会让业务模型更清晰,还是只会制造更多小类型?
- 如果 OpenAPI 和序列化工具支持不完整,你会在生产项目中采用吗?
- 你认为 C# 正在变得更有表达力,还是越来越复杂?
联合类型真正考验的可能不是我们会不会写新的语法,而是我们能不能认真定义:一个操作究竟允许产生哪些结果。
参考资料
- Microsoft .NET Blog:Explore new features available in C# 15 preview
https://devblogs.microsoft.com/dotnet/explore-csharp-15/ - .NET 11 Preview 7 Runtime Release Notes
https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/preview7/runtime.md
浙公网安备 33010602011771号