Skip to content

feat(serialization): tagged-union serialization for closed hierarchies and unions - #11477

Open
ReubenBond wants to merge 28 commits into
dotnet:mainfrom
ReubenBond:rb-tagged-union-serialization-proposal
Open

ReubenBond wants to merge 28 commits into
dotnet:mainfrom
ReubenBond:rb-tagged-union-serialization-proposal

Conversation

@ReubenBond

@ReubenBond ReubenBond commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

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] public closed record Sensor;
[GenerateSerializer, Case(0)] public sealed record Temperature([property: Id(0)] double Celsius) : Sensor;
[GenerateSerializer, Case(1)] public record Contact([property: Id(0)] bool Open) : Sensor; // unsealed: subclasses encode their runtime type

[GenerateSerializer] public union Pet(Cat, Dog);
[GenerateSerializer, CaseIds(0, 2)] public union PetV2(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.
Microsoft Reviewers: Open in CodeFlow

Copilot AI balanced review requested due to automatic review settings October 12, 2026 20:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic subtype constraints can break generated compilation, and ORLEANS0028 currently reports false positives.

2 open findings
What changed in this PR

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.

Automated PR coverage results were unavailable.

File Description
test/​Orleans.Serialization.UnitTests/​TaggedUnionSerializationTests.cs Exercises tagged-union serialization and copying.
test/​Orleans.Serialization.UnitTests/​LanguageFeaturePolyfills.cs Adds pre-.NET 11 compiler polyfills.
src/​Orleans.Serialization/​GeneratedCodeHelpers/​OrleansGeneratedCodeHelper.cs Adds tagged-union runtime helpers.
src/​Orleans.Serialization/​Exceptions.cs Adds unknown-case exception.
src/​Orleans.Serialization.Abstractions/​Annotations.cs Adds case-ID attributes.
src/​Orleans.CodeGenerator/​UnionCodecGenerator.cs Generates union codecs and copiers.
src/​Orleans.CodeGenerator/​UnionCases.cs Resolves and validates union cases.
src/​Orleans.CodeGenerator/​SerializerGenerator.cs Integrates closed-type codecs.
src/​Orleans.CodeGenerator/​SerializerFactoryGenerator.cs Rejects unions in serializer contexts.
src/​Orleans.CodeGenerator/​SerializableSourceOutputGenerator.cs Creates union type descriptions.
src/​Orleans.CodeGenerator/​Orleans.CodeGenerator.csproj Links language-feature helpers.
src/​Orleans.CodeGenerator/​Model/​SerializableTypeModelExtractor.cs Extracts union models.
src/​Orleans.CodeGenerator/​Model/​SerializableTypeDescription.cs Models union cases and copyability.
src/​Orleans.CodeGenerator/​LibraryTypes.cs Resolves case attributes.
src/​Orleans.CodeGenerator/​Diagnostics/​InvalidUnionCaseDiagnostic.cs Defines ORLEANS0116 diagnostics.
src/​Orleans.CodeGenerator/​Diagnostics/​DiagnosticRuleId.cs Registers ORLEANS0116.
src/​Orleans.CodeGenerator/​CopierGenerator.cs Selects union copier generation.
src/​Orleans.CodeGenerator/​AnalyzerReleases.Unshipped.md Records ORLEANS0116.
src/​Orleans.CodeGenerator.Shared/​LanguageFeatureSymbols.cs Reflects newer Roslyn APIs.
src/​Orleans.Analyzers/​UnionCaseOverlapAnalyzer.cs Implements ORLEANS0028.
src/​Orleans.Analyzers/​Orleans.Analyzers.csproj Links language-feature helpers.
src/​Orleans.Analyzers/​AnalyzerReleases.Unshipped.md Records ORLEANS0028.
src/​api/​Orleans.Serialization/​Orleans.Serialization.cs Updates generated serialization API.
src/​api/​Orleans.Serialization.Abstractions/​Orleans.Serialization.Abstractions.cs Updates generated annotation API.
global.json Selects the .NET 11 RC SDK.
docs/​site/​src/​content/​docs/​toc.yml Adds ORLEANS0028 navigation.
docs/​site/​src/​content/​docs/​diagnostics/​orleans0028.md Documents ORLEANS0028.
docs/​site/​src/​content/​docs/​diagnostics/​index.md Indexes ORLEANS0028.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Orleans.CodeGenerator/UnionCases.cs Resolved
Copilot AI balanced review requested due to automatic review settings October 12, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic constraint handling can emit invalid serializers and produce false-positive overlap diagnostics.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Orleans.Analyzers/TypeUnifier.cs Resolved
Copilot AI balanced review requested due to automatic review settings October 12, 2026 21:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core wire-format and code-generation behavior while CI and automated coverage results remain unavailable.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 12, 2026 21:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The compatibility-sensitive wire-format, runtime, and preview-language code-generation changes warrant final human validation.

0 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 12, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Generic case validation omits nullability constraints and can emit source that fails compilation.

0 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Missing nullability-constraint handling can produce invalid generated code and false analyzer warnings.

1 open finding

🧠 Review effort: Balanced

Comment thread src/Orleans.CodeGenerator/UnionCases.cs Resolved
Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic case validation misses nullability constraints and can emit source that fails compilation.

3 open findings

🧠 Review effort: Balanced

Comment thread src/Orleans.CodeGenerator/UnionCases.cs Resolved
Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic case validation still omits nullability constraints, allowing generated serializers to fail compilation.

3 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Generic constraint validation misses notnull and nullable-reference distinctions, and ORLEANS0116 lacks focused generator tests.

3 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Add generator diagnostic tests for ORLEANS0116 code paths

src/​Orleans.CodeGenerator/​Diagnostics/​InvalidUnionCaseDiagnostic.cs:10

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.

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The Roslyn version override causes an NU1109 restore failure across CI.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

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.
@ReubenBond
ReubenBond force-pushed the rb-tagged-union-serialization-proposal branch from f9761f7 to 0a92b84 Compare October 30, 2026 02:26
Copilot AI balanced review requested due to automatic review settings October 30, 2026 02:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

2 open issues

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 30, 2026 23:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

…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.
Copilot AI balanced review requested due to automatic review settings October 31, 2026 00:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

No open issues 🎉

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants