You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(serialization): tagged-union serialization for closed hierarchies and unions - #11477
C# 15 adds closed type hierarchies and union types. Today, a field declared as an abstract base carries the runtime type of its value: the first occurrence in a message encodes the full type name (roughly 35–60 bytes), and reading it requires type resolution, an allow-list check, and a codec lookup. This PR serializes closed hierarchies and unions as tagged unions, with a small integer tag scoped to the declared type.
How cases are identified
Closed hierarchies: each direct subtype declares [Case(n)], numbered within its closed parent. Adding [Case] opts the hierarchy in. Once any subtype has one, all direct subtypes must.
Unions: each case is identified by its position in the declaration. [CaseIds(...)] assigns one ID per case in declaration order, so cases can be reordered or retired without renumbering. It never names a type, so unions such as Either<L, R>(Value<L>, Value<R>) stay unambiguous.
[GenerateSerializer]publicclosedrecord Sensor;[GenerateSerializer,Case(0)]publicsealedrecordTemperature([property:Id(0)]doubleCelsius):Sensor;[GenerateSerializer,Case(1)]publicrecordContact([property:Id(0)]boolOpen):Sensor;// unsealed: subclasses encode their runtime type[GenerateSerializer]publicunionPet(Cat,Dog);[GenerateSerializer,CaseIds(0,2)]publicunionPetV2(Cat,Dog);
Wire format
A tagged-union value is a tag-delimited field containing at most one inner field. The inner field's ID is the case ID (0–6 fit in the tag byte), and its expected type is the case type. The existing expected-type optimization therefore applies: an exact case match encodes no type. A subclass of an unsealed case encodes its runtime type through the existing type IDs (WellKnown, Referenced, or Encoded).
No new wire type is needed. Readers that don't understand the field skip it as an ordinary tag-delimited field.
The outer field reserves one reference ID, matching what a skipping reader records. References that point into a skipped union field therefore still resolve.
A union value costs 2 bytes more than its case serialized with the expected type.
Closed hierarchies and unions share one encoding. A closed type and a union whose cases are its direct subtypes, with the same case IDs, encode identically. A null closed-type value is an empty union field, the same encoding as an empty union value. A closed hierarchy can therefore be replaced by an equivalent union later (for example, once .NET 11 is the minimum) without changing serialized data. To remove the base class from the cases at that point, give the closed base no serialized members and mark it [SerializerTransparent]. Verify snapshot tests lock these encodings and assert that equivalent closed and union values produce identical bytes and read each other's data.
Readers of both kinds also accept payloads written before a closed type adopted [Case] (null references, and values with their runtime type encoded in the field header), so existing persisted state remains readable.
An unknown case ID throws UnknownUnionCaseException.
When two union cases have the same type (for example Either<int, int>), a value is written as the first matching case, the same case pattern matching selects. The union stores only its value, so the round trip preserves everything the union can observe. The compiler rejects ambiguous construction for such cases.
Code generation
Closed-type codecs implement IFieldCodec<T> and IBaseCodec<T>. Writes switch on the value's type and call the case codec (the concrete generated codec where available). Reads switch on the case ID. The base type's members are still serialized for subtypes.
Struct-union codecs implement IFieldCodec<T> and IValueSerializer<T>. They deconstruct values with union pattern matching and rebuild them with union conversions, so custom unions with non-boxing access work too.
Copiers: struct unions get a case-switching copier. A union whose cases are all shallow-copyable uses ShallowCopier<T>.
Generic closed hierarchies are supported when each case passes its own type parameters as the closed type's type arguments, as in Circle<T> : Shape<T>. A subtype of a single instantiation, such as IntSquare : Shape<int>, isn't a case of every instantiation and is rejected.
Generated source: codec bodies are written as C# text, parsed, and normalized like the rest of the generated source. A debug assertion checks that each template parses.
Compatibility with older compilers: the generator still compiles against Roslyn 4.5. It reads IsClosed, IsUnion, and UnionCaseTypes through a small reflection helper (LanguageFeatureSymbols), so it keeps loading in older compilers.
New diagnostic ORLEANS0116 (error, generator) reports:
missing or duplicate case IDs, and a [CaseIds] count mismatch,
cases that don't pass the closed type's type parameters through, and case constraints the closed type doesn't declare,
inaccessible cases, and class-based unions,
unions built with a compiler that doesn't expose case types. Roslyn 5.9 exposes IsUnion but not UnionCaseTypes, which would otherwise produce a codec with no cases.
Generator tests: the net10.0 generator tests reference Roslyn 5.9, which parses closed types and unions, and cover the ORLEANS0116 rules. net8.0 generator tests keep running on the oldest supported Roslyn.
Serializer contexts ([GenerateSerializerContext<T>]) report unions as unsupported, the same way they already report abstract types.
Toolchain
global.json moves to SDK 11.0.100-rc.1.26425.128. Product and test TFMs are unchanged. The tests declare closed types and unions on net8.0 and net10.0 using internal polyfills of the compiler-required types (IsClosedTypeAttribute, UnionAttribute, IUnion).
Not in this PR
Generator multi-targeting: once the Roslyn package that ships with .NET 11 is on nuget.org (5.11 in RC1; the newest on nuget.org is 5.9.0), the generator can ship a second build under analyzers/dotnet/roslyn5.11/cs, alongside the Roslyn 4.5 build. That second build calls the symbol APIs directly instead of through reflection. The SDK loads the build matching the running compiler. The generator tests can then also cover the union rules that need UnionCaseTypes: [CaseIds] count and duplicate IDs, and class-based unions.
Rolling upgrades: there's no runtime switch to keep writing the legacy encoding during a rolling upgrade of an existing abstract base, so adopting [Case] changes the written format immediately. New readers accept both formats.
Conceptual docs wait for a released package that contains these APIs; the docs guidance requires compilable snippets against released packages.
Adds tagged-union serialization for C# 15 unions and closed hierarchies, including public annotations, generated codecs/copiers, diagnostics, compatibility handling, and tests.
Changes:
Generates compact case-tagged codecs and copiers.
Adds case-ID APIs, exceptions, diagnostics, and documentation.
Updates tests, polyfills, and the .NET 11 RC toolchain.
No generator test currently exercises ORLEANS0116, so the new missing/duplicate/count, generic-constraint, accessibility, class-union, and compiler-capability diagnostic paths can regress unnoticed. Add focused cases to the existing code-generator diagnostic suite, including assertions for diagnostic location and message; the runtime serialization tests only cover valid declarations.
Run net10.0 generator tests on Roslyn 5.9, which parses closed types and unions, and add diagnostic tests for case identifiers, generic case mapping and constraints, inaccessible cases, and unavailable union case types. net8.0 generator tests keep using the oldest supported Roslyn. Dispose the serializer service provider in tagged-union serialization tests.
Move the Roslyn 5.9 test dependency from a project-level override to a central rule for net10.0 test applications, so projects which reference Orleans.CodeGenerator.Tests resolve the same version. Product packages keep their Roslyn dependencies.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The persisted wire contract and closed-hierarchy-to-union migration guarantees warrant maintainer review because they rely on preview-compiler symbol APIs, and SDK/runtime interoperability could not be tested locally.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because imported unions can be misclassified on supported Roslyn 4.5 hosts, bypassing copier fallback and causing generated code to fail compilation.
…APIs
Compilers which predate union symbol APIs can still reference union types compiled by newer compilers, and can declare custom unions with UnionAttribute. Treating those types as ordinary structs selected generated copiers which shallow unions do not emit, and serialized custom unions by fields instead of the tagged format. Identify unions by UnionAttribute metadata when the API is unavailable, and run the referenced-union and custom-union regressions on the Roslyn 4.5 test target.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The new persisted wire format and preview-Roslyn reflection bridge need maintainer compatibility sign-off because exact-revision validation across supported runtimes and compiler versions was unavailable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
C# 15 adds
closedtype hierarchies anduniontypes. Today, a field declared as an abstract base carries the runtime type of its value: the first occurrence in a message encodes the full type name (roughly 35–60 bytes), and reading it requires type resolution, an allow-list check, and a codec lookup. This PR serializes closed hierarchies and unions as tagged unions, with a small integer tag scoped to the declared type.How cases are identified
[Case(n)], numbered within its closed parent. Adding[Case]opts the hierarchy in. Once any subtype has one, all direct subtypes must.[CaseIds(...)]assigns one ID per case in declaration order, so cases can be reordered or retired without renumbering. It never names a type, so unions such asEither<L, R>(Value<L>, Value<R>)stay unambiguous.Wire format
A tagged-union value is a tag-delimited field containing at most one inner field. The inner field's ID is the case ID (0–6 fit in the tag byte), and its expected type is the case type. The existing expected-type optimization therefore applies: an exact case match encodes no type. A subclass of an unsealed case encodes its runtime type through the existing type IDs (
WellKnown,Referenced, orEncoded).nullclosed-type value is an empty union field, the same encoding as an empty union value. A closed hierarchy can therefore be replaced by an equivalent union later (for example, once .NET 11 is the minimum) without changing serialized data. To remove the base class from the cases at that point, give the closed base no serialized members and mark it[SerializerTransparent]. Verify snapshot tests lock these encodings and assert that equivalent closed and union values produce identical bytes and read each other's data.[Case](null references, and values with their runtime type encoded in the field header), so existing persisted state remains readable.UnknownUnionCaseException.Either<int, int>), a value is written as the first matching case, the same case pattern matching selects. The union stores only its value, so the round trip preserves everything the union can observe. The compiler rejects ambiguous construction for such cases.Code generation
IFieldCodec<T>andIBaseCodec<T>. Writes switch on the value's type and call the case codec (the concrete generated codec where available). Reads switch on the case ID. The base type's members are still serialized for subtypes.IFieldCodec<T>andIValueSerializer<T>. They deconstruct values with union pattern matching and rebuild them with union conversions, so custom unions with non-boxing access work too.ShallowCopier<T>.Circle<T> : Shape<T>. A subtype of a single instantiation, such asIntSquare : Shape<int>, isn't a case of every instantiation and is rejected.IsClosed,IsUnion, andUnionCaseTypesthrough a small reflection helper (LanguageFeatureSymbols), so it keeps loading in older compilers.ORLEANS0116(error, generator) reports:[CaseIds]count mismatch,IsUnionbut notUnionCaseTypes, which would otherwise produce a codec with no cases.[GenerateSerializerContext<T>]) report unions as unsupported, the same way they already report abstract types.Toolchain
global.jsonmoves to SDK11.0.100-rc.1.26425.128. Product and test TFMs are unchanged. The tests declare closed types and unions on net8.0 and net10.0 using internal polyfills of the compiler-required types (IsClosedTypeAttribute,UnionAttribute,IUnion).Not in this PR
analyzers/dotnet/roslyn5.11/cs, alongside the Roslyn 4.5 build. That second build calls the symbol APIs directly instead of through reflection. The SDK loads the build matching the running compiler. The generator tests can then also cover the union rules that needUnionCaseTypes:[CaseIds]count and duplicate IDs, and class-based unions.[Case]changes the written format immediately. New readers accept both formats.Microsoft Reviewers: Open in CodeFlow