C# native union 降级实录:从 wrapper record 到类型擦除
C# native union 降级实录:从类型包装到类型擦除
.NET 11 SDK
11.0.100-preview.6.26359.118已经把 C# native union 带进了真实的编译器工作流。Jazor 的做法不是把所有 union 都改写成一个万能包装器,而是根据分支是否互斥,在原生 union 和 tagged fallback 之间作出确定选择。
一、Union 是什么:代数数据类型的“或”
编程语言里的类型可以看成值的集合。struct 或 class 组合多个字段,是积类型;union 则表示一个值来自若干候选类型中的一个,是和类型:
union IntOrString = int | string
它适合表达 API 的有限值域。例如一个 Web API 参数可能是一个标量,也可能是一个配置对象;一个 DOM API 参数可能接受字符串、元素或数组。把参数声明成 object 虽然省事,却把合法分支、赋值检查和调用点提示都交给了运行时。
二、C# 过去的替代方案
在 native union 之前,C# 常见的做法是继承树、OneOf<T1, T2>、枚举加 payload,或者直接使用 object。
继承树适合 AST 这类本来就有稳定层次的模型,但为“字符串或数组”建立一组类型会产生不必要的名义类型。OneOf 一类泛型包装器可以复用实现,却不是 C# 编译器认识的原生 union,switch 收窄、隐式赋值和 Razor 参数绑定仍然需要额外约定。object 则直接放弃了 authored API 的类型边界。
这也是 native union 有价值的地方:它把候选分支写进声明,让普通赋值、隐式构造、重载解析和生成器都能看到同一个公共合同。
三、Preview 6 的 native union 语法
当前项目固定到 .NET 11 Preview 6 / C# 15 preview SDK。最基本的声明形态是:
[ECMAScript]
[System.Runtime.CompilerServices.Union]
public readonly union NumberOrString(double, string)
{
public double? AsDouble => Value is double value ? value : default;
public string? AsString => Value is string value ? value : default;
public static implicit operator NumberOrString(double value)
=> new(value);
public static implicit operator NumberOrString(string value)
=> new(value);
}
使用端不需要手写 From(...):
NumberOrString number = 3.14;
NumberOrString text = "hello";
if (number.AsDouble is double value)
Console.WriteLine(value * 2);
这里有几个 Preview 6 语境下必须说清楚的事实:
union是 C# 的声明语法,不是“写一个 record 再加一个 Attribute”的别名。- 分支类型写在
union Name(T1, T2, ...)中,由语言和 SDK 提供 union 合同。 - native union 对外仍通过
System.Runtime.CompilerServices.UnionAttribute和IUnion被识别;IUnion.Value是编译器/运行时合同,不应据此把公共 API 退化成object。 AsXxx是 authoring 侧的强类型投影。Jazor 在 JavaScript 输出侧会擦除 union 容器,但不会因此删除 C# 侧的分支类型和投影属性。
Preview 6 的重点不是“所有 union 都自动变成无包装裸值”,而是语言原生 union 已经可以作为稳定的首选表达。能否使用它,还要看分支之间的可赋值关系。
四、为什么分支必须互不可赋值
erased-value union 通常依靠值的实际类型来判断 AsXxx 投影。如果一个分支可以赋值给另一个分支,类型检查就不再能唯一确定原始分支:
public sealed class ScrollPositionCoordinates;
public sealed class ScrollPositionElement : ScrollPositionCoordinates;
// 这两个分支存在继承关系,不适合直接使用 native erased union
public readonly union ScrollPosition(
ScrollPositionElement,
ScrollPositionCoordinates);
当值是 ScrollPositionElement 时,value is ScrollPositionCoordinates 同时为真。于是 AsScrollPositionCoordinates 无法表达“它实际选择了哪个分支”,投影会被基类匹配扩大。
因此当前规则是:
double与string这类互不可赋值分支,优先使用 native union。- 分支存在继承关系时,使用 tagged fallback。
- 分支是
object、宽接口或 delegate,且需要精确区分具体投影时,也使用 tagged fallback。
这不是绕过 Preview 6 的临时方案,而是 erased union 必须遵守的语义边界。仓库测试会检查 fallback union 的分支不可赋值条件,避免生成一个看似合法、投影却不精确的 native union。
五、Jazor 的两种 union 产物
Jazor 使用三个条件识别一个可以进入 host lowering 的 union:
[System.Runtime.CompilerServices.Union]
+ [ECMAScript]
+ System.Runtime.CompilerServices.IUnion
Jazor.Compiler 在 Roslyn symbol 上检查这些合同,而不是重新解析 public readonly union 的语法文本。识别结果只决定“这个类型是否属于 Jazor 的 erased union host domain”,不改变 CLR API 的 authored 形状。
5.1 原生 union:互斥分支
例如 WebIDL 的 CSSNumberish 当前生成成:
[ECMAScript]
[System.Runtime.CompilerServices.Union]
[Description("@#")]
public readonly union CSSNumberish(double, CSSNumericValue)
{
public double? AsDouble => Value is double value ? value : default;
public CSSNumericValue? AsCSSNumericValue
=> Value is CSSNumericValue value ? value : default;
public static implicit operator CSSNumberish(double value) => new(value);
public static implicit operator CSSNumberish(CSSNumericValue value) => new(value);
}
生成器保留了隐式赋值和 AsXxx 投影。对于带数组分支的 union,还会生成 CollectionBuilder 和 IEnumerable<T> 适配,使 collection expression 仍然可以绑定到强类型 API。
5.2 tagged fallback:需要精确标签
当 native union 不能安全表达分支关系时,WebIDL 生成器生成显式的只读 struct:
[ECMAScript]
[System.Runtime.CompilerServices.Union]
public readonly struct BufferSource : System.Runtime.CompilerServices.IUnion
{
private readonly byte _kind;
private readonly IArrayBufferView? _value1;
private readonly ArrayBuffer? _value2;
public IArrayBufferView? AsIArrayBufferView
=> _kind == 1 ? _value1 : default;
public ArrayBuffer? AsArrayBuffer
=> _kind == 2 ? _value2 : default;
public object? Value => _kind switch
{
1 => _value1,
2 => _value2,
_ => default
};
}
fallback 不是旧式的泛型 wrapper record,也不是把所有输入都收进 object 参数。它是一个带明确分支标签的公共值合同。对于接口或 object 分支,生成器会避免产生不安全的 implicit operator,必要时提供强类型 FromXxx 入口;具体分支仍会保留可以安全提供的隐式转换。
例如 object 分支不能提供 implicit operator Union(object value),因为任何值都能匹配它,会吞掉其他分支的重载解析。这个限制是有意的,避免用一个宽转换破坏 C# 的类型安全。
六、WebIDL 生成器为什么不能只生成一种形态
当前生成器会先把 WebIDL union 分支映射为 C# 类型,再分析:
- 删除 void-like 分支并合并重复类型。
- 计算命名 union 和嵌套 union 的稳定名称。
- 识别数组分支以及
CollectionBuilder需求。 - 判断是否满足 native union 的分支约束。
- 满足约束时生成
public readonly union;否则生成[Union] + IUniontagged struct。
这条规则也覆盖嵌套 union。例如 Promise 的 union 结果会先得到命名的内部 union,再把它作为 Promise 的类型参数,而不是把匿名 object 塞进外层 API。生成器测试覆盖了 typedef、dictionary、constructor、operation、callback interface、数组分支和嵌套 union。
七、Jazor 如何把 union 擦除到 JavaScript
Jazor 的 compiler 工作在 Roslyn IOperation 上。对于已经识别为 host erased union 的类型,union 容器本身不需要在 JavaScript 中生成一套 CLR 类层次。
CSSNumberish width = 100;
if (width.AsDouble is double value)
Console.WriteLine(value);
在 lowering 后,width 仍然是 JavaScript 的实际值,AsDouble 这个 C# 投影不会变成一个运行时 wrapper 属性。编译器根据具体投影和模式检查生成对应的值判断,同时保留单次求值和使用点语义。
这个规则只作用于 Jazor 已确认的 host union。一个普通类型即使自己实现了同名接口,或者只有 [Union] 没有 [ECMAScript],都不能因此被静默当成 erased union;不受支持的 union 投影会在 compiler 使用点明确失败。
default(union) 也有明确语义:erased union 没有一个可以代表“已选择分支”的 CLR default 对象,Jazor 在 JavaScript 侧使用 null 表示未初始化值,而不是生成一个伪造的 union 实例。
八、Preview 6 下的公共 API 规则
native union 的引入并不意味着宿主 API 可以重新使用 object 或开放泛型。当前规则是:
- 已知的 JavaScript 值域优先用命名 native union 表达。
- native union 分支互不可赋值时才直接使用。
- 需要精确 tagged projection 时使用
[Union] + IUnionfallback。 - 保留强类型隐式赋值、必要的
AsXxx投影、collection builder 和 Razor Source Generator 可绑定的参数形状。 - 不为“JavaScript 可以接收任何值”增加
object?catch-all 参数。 - 不把已经退休的旧泛型 union wrapper 重新接回 compiler recognition path。
这条规则同时约束 C# 项目、WebIDL 生成器、Vue/DOM binding、Razor SG 参数绑定和 Jazor compiler。只有普通 C# 编译通过、Razor Source Generator 能生成最终 Compilation、Jazor 又能在使用点正确 lowering,才算一个完整的 union 合同。
九、小结
Preview 6 带来的真正变化可以概括为:
互不可赋值的分支
-> public readonly union Name(T1, T2, ...)
存在继承、object/interface/delegate 精确投影需求
-> [Union] + IUnion tagged fallback
两者都属于强类型 C# host contract
-> Jazor compiler 在使用点擦除 union 容器
-> JavaScript 保留实际分支值和可观察行为
所以这不是“wrapper record 终于被一个神奇语法替代”的故事。Preview 6 提供了更好的原生表达,Jazor 仍然必须处理分支关系、Razor 绑定、集合构造、投影精度和 JavaScript lowering。原生 union 是默认答案,但不是无条件答案;tagged fallback 也不是失败,而是当类型关系要求标签时更准确的答案。

浙公网安备 33010602011771号