This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new a12e77695 feat(c#): use sealed record for c# union (#3710)
a12e77695 is described below

commit a12e776951a263f3596a5cf6e4057ae33bc220b5
Author: Shawn Yang <[email protected]>
AuthorDate: Wed May 27 16:53:59 2026 +0800

    feat(c#): use sealed record for c# union (#3710)
    
    ## Why?
    
    
    
    ## What does this PR do?
    
    
    
    ## Related issues
    
    
    
    ## AI Contribution Checklist
    
    
    
    - [ ] Substantial AI assistance was used in this PR: `yes` / `no`
    - [ ] If `yes`, I included a completed [AI Contribution
    
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
    in this PR description and the required `AI Usage Disclosure`.
    - [ ] If `yes`, my PR description includes the required `ai_review`
    summary and screenshot evidence of the final clean AI review results
    from both fresh reviewers on the current PR diff or current HEAD after
    the latest code changes.
    
    
    
    ## Does this PR introduce any user-facing change?
    
    
    
    - [ ] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
---
 compiler/fory_compiler/generators/csharp.py        |  80 +--
 .../fory_compiler/tests/test_csharp_generator.py   |  28 +
 .../Fory.Generator/AnalyzerReleases.Unshipped.md   |   2 +
 csharp/src/Fory.Generator/ForyModelGenerator.cs    | 646 ++++++++++++++++++++-
 csharp/src/Fory/AnySerializer.cs                   |  19 +-
 csharp/src/Fory/Attributes.cs                      |  29 +-
 csharp/src/Fory/TypeInfo.cs                        |   3 +-
 csharp/tests/Fory.Tests/ForyGeneratorTests.cs      |  19 +-
 csharp/tests/Fory.Tests/ForyRuntimeTests.cs        |  52 ++
 docs/compiler/generated-code.md                    |  22 +-
 docs/guide/csharp/schema-metadata.md               |  29 +-
 docs/guide/csharp/supported-types.md               |   2 +-
 docs/specification/xlang_type_mapping.md           |   2 +-
 .../idl_tests/csharp/IdlTests/RoundtripTests.cs    | 175 +++---
 14 files changed, 909 insertions(+), 199 deletions(-)

diff --git a/compiler/fory_compiler/generators/csharp.py 
b/compiler/fory_compiler/generators/csharp.py
index c9d1716fa..303feb1bd 100644
--- a/compiler/fory_compiler/generators/csharp.py
+++ b/compiler/fory_compiler/generators/csharp.py
@@ -545,6 +545,9 @@ class CSharpGenerator(BaseGenerator):
 
         raise ValueError(f"Unknown field type: {field_type}")
 
+    def _union_case_type_name(self, field: Field) -> str:
+        return self.safe_identifier(self.to_pascal_case(field.name))
+
     def _default_initializer(
         self, field: Field, parent_stack: List[Message]
     ) -> Optional[str]:
@@ -697,7 +700,6 @@ class CSharpGenerator(BaseGenerator):
         lines: List[str] = []
         ind = self.indent_str * indent
         type_name = self.safe_type_identifier(union.name)
-        case_enum = self.safe_type_identifier(f"{union.name}Case")
         registration_class = self.get_registration_class_name()
         full_type_ref = self._type_reference_for_local(union)
 
@@ -705,88 +707,38 @@ class CSharpGenerator(BaseGenerator):
         if comment:
             lines.append(comment)
         lines.append(f"{ind}[ForyUnion]")
-        lines.append(f"{ind}public sealed class {type_name} : Union")
+        lines.append(f"{ind}public abstract partial record {type_name}")
         lines.append(f"{ind}{{")
-        lines.append(f"{ind}{self.indent_str}public enum {case_enum}")
-        lines.append(f"{ind}{self.indent_str}{{")
-        lines.append(f"{ind}{self.indent_str * 2}Unknown = 0,")
-        for i, field in enumerate(union.fields):
-            comma = "," if i < len(union.fields) - 1 else ""
-            case_name = self.safe_identifier(self.to_pascal_case(field.name))
-            lines.append(
-                f"{ind}{self.indent_str * 2}{case_name} = 
{field.number}{comma}"
-            )
-        lines.append(f"{ind}{self.indent_str}}}")
-        lines.append("")
-
-        lines.append(
-            f"{ind}{self.indent_str}private {type_name}(int index, object? 
value) : base(index, value)"
-        )
+        lines.append(f"{ind}{self.indent_str}private {type_name}()")
         lines.append(f"{ind}{self.indent_str}{{")
         lines.append(f"{ind}{self.indent_str}}}")
         lines.append("")
 
+        lines.append(f"{ind}{self.indent_str}[ForyCase(0)]")
         lines.append(
-            f"{ind}{self.indent_str}public static {type_name} Of(int index, 
object? value)"
+            f"{ind}{self.indent_str}public sealed partial record 
UnknownCase(int CaseId, object? Value) : {type_name};"
         )
-        lines.append(f"{ind}{self.indent_str}{{")
-        lines.append(f"{ind}{self.indent_str * 2}return new {type_name}(index, 
value);")
-        lines.append(f"{ind}{self.indent_str}}}")
         lines.append("")
 
         for field in union.fields:
-            case_name = self.safe_identifier(self.to_pascal_case(field.name))
+            case_name = self._union_case_type_name(field)
             case_type = self.generate_type(
                 field.field_type,
                 nullable=False,
                 parent_stack=parent_stack,
             )
+            schema_type = self._schema_type_hint(field.field_type)
+            if schema_type:
+                lines.append(
+                    f"{ind}{self.indent_str}[ForyCase({field.number}, Type = 
typeof({schema_type}))]"
+                )
+            else:
+                
lines.append(f"{ind}{self.indent_str}[ForyCase({field.number})]")
             lines.append(
-                f"{ind}{self.indent_str}public static {type_name} 
{case_name}({case_type} value)"
-            )
-            lines.append(f"{ind}{self.indent_str}{{")
-            lines.append(
-                f"{ind}{self.indent_str * 2}return new 
{type_name}({field.number}, value);"
-            )
-            lines.append(f"{ind}{self.indent_str}}}")
-            lines.append("")
-
-            lines.append(
-                f"{ind}{self.indent_str}public bool Is{case_name} => Index == 
{field.number};"
-            )
-            lines.append("")
-            lines.append(f"{ind}{self.indent_str}public {case_type} 
{case_name}Value()")
-            lines.append(f"{ind}{self.indent_str}{{")
-            lines.append(f"{ind}{self.indent_str * 2}if (!Is{case_name})")
-            lines.append(f"{ind}{self.indent_str * 2}{{")
-            lines.append(
-                f'{ind}{self.indent_str * 3}throw new 
InvalidOperationException("Union does not hold case {case_name}");'
+                f"{ind}{self.indent_str}public sealed partial record 
{case_name}({case_type} Value) : {type_name};"
             )
-            lines.append(f"{ind}{self.indent_str * 2}}}")
-            lines.append(f"{ind}{self.indent_str * 2}return 
GetValue<{case_type}>();")
-            lines.append(f"{ind}{self.indent_str}}}")
             lines.append("")
 
-        lines.append(f"{ind}{self.indent_str}public {case_enum} Case()")
-        lines.append(f"{ind}{self.indent_str}{{")
-        lines.append(f"{ind}{self.indent_str * 2}return Index switch")
-        lines.append(f"{ind}{self.indent_str * 2}{{")
-        for field in union.fields:
-            case_name = self.safe_identifier(self.to_pascal_case(field.name))
-            lines.append(
-                f"{ind}{self.indent_str * 3}{field.number} => 
{case_enum}.{case_name},"
-            )
-        lines.append(f"{ind}{self.indent_str * 3}_ => {case_enum}.Unknown,")
-        lines.append(f"{ind}{self.indent_str * 2}}};")
-        lines.append(f"{ind}{self.indent_str}}}")
-        lines.append("")
-
-        lines.append(f"{ind}{self.indent_str}public int CaseId()")
-        lines.append(f"{ind}{self.indent_str}{{")
-        lines.append(f"{ind}{self.indent_str * 2}return Index;")
-        lines.append(f"{ind}{self.indent_str}}}")
-        lines.append("")
-
         lines.append(f"{ind}{self.indent_str}public byte[] ToBytes()")
         lines.append(f"{ind}{self.indent_str}{{")
         lines.append(
diff --git a/compiler/fory_compiler/tests/test_csharp_generator.py 
b/compiler/fory_compiler/tests/test_csharp_generator.py
index 0c1c78a9b..85ac252fe 100644
--- a/compiler/fory_compiler/tests/test_csharp_generator.py
+++ b/compiler/fory_compiler/tests/test_csharp_generator.py
@@ -78,8 +78,14 @@ def test_csharp_semantic_model_attributes():
             READY = 1;
         }
 
+        message Item {
+            string name = 1;
+        }
+
         union Choice {
             string text = 1;
+            fixed int32 code = 2;
+            Item item = 3;
         }
 
         message Envelope {
@@ -91,6 +97,28 @@ def test_csharp_semantic_model_attributes():
 
     assert "[ForyEnum]" in file.content
     assert "[ForyUnion]" in file.content
+    assert "public abstract partial record Choice" in file.content
+    assert "[ForyCase(0)]" in file.content
+    assert (
+        "public sealed partial record UnknownCase(int CaseId, object? Value) : 
Choice;"
+        in file.content
+    )
+    assert "[ForyCase(1)]" in file.content
+    assert "public sealed partial record Text(string Value) : Choice;" in 
file.content
+    assert "[ForyCase(2, Type = typeof(S.Fixed<S.Int32>))]" in file.content
+    assert "public sealed partial record Code(int Value) : Choice;" in 
file.content
+    assert "[ForyCase(3)]" in file.content
+    assert (
+        "public sealed partial record Item(global::example.Item Value) : 
Choice;"
+        in file.content
+    )
+    assert ": Union" not in file.content
+    assert "public enum ChoiceCase" not in file.content
+    assert "public static Choice Text" not in file.content
+    assert "public bool IsText" not in file.content
+    assert "TextValue()" not in file.content
+    assert "Case()" not in file.content
+    assert "GetCaseId()" not in file.content
     assert "[ForyStruct]" in file.content
     assert "[ForyObject]" not in file.content
 
diff --git a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md 
b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
index d67ff3684..b7b6eff1f 100644
--- a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
+++ b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
@@ -7,3 +7,5 @@ FORY002 | Fory | Error | Missing parameterless constructor
 FORY003 | Fory | Error | Unsupported Field encoding
 FORY004 | Fory | Error | Invalid Fory field id
 FORY005 | Fory | Error | Invalid Fory union type
+FORY006 | Fory | Error | Invalid Fory union case
+FORY007 | Fory | Error | Duplicate Fory union case id
diff --git a/csharp/src/Fory.Generator/ForyModelGenerator.cs 
b/csharp/src/Fory.Generator/ForyModelGenerator.cs
index 05f5faefb..6248065b7 100644
--- a/csharp/src/Fory.Generator/ForyModelGenerator.cs
+++ b/csharp/src/Fory.Generator/ForyModelGenerator.cs
@@ -66,7 +66,23 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
     private static readonly DiagnosticDescriptor InvalidUnionType = new(
         id: "FORY005",
         title: "Invalid Fory union type",
-        messageFormat: "Class '{0}' must derive from Apache.Fory.Union for 
[ForyUnion]",
+        messageFormat: "Class '{0}' must declare nested [ForyCase] case types 
for [ForyUnion]",
+        category: "Fory",
+        defaultSeverity: DiagnosticSeverity.Error,
+        isEnabledByDefault: true);
+
+    private static readonly DiagnosticDescriptor InvalidUnionCase = new(
+        id: "FORY006",
+        title: "Invalid Fory union case",
+        messageFormat: "Union case '{0}' is invalid: {1}",
+        category: "Fory",
+        defaultSeverity: DiagnosticSeverity.Error,
+        isEnabledByDefault: true);
+
+    private static readonly DiagnosticDescriptor DuplicateUnionCaseId = new(
+        id: "FORY007",
+        title: "Duplicate Fory union case id",
+        messageFormat: "Union case id {0} is declared more than once in '{1}'",
         category: "Fory",
         defaultSeverity: DiagnosticSeverity.Error,
         isEnabledByDefault: true);
@@ -141,6 +157,11 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
                 EmitObjectSerializer(sb, model);
                 sb.AppendLine();
             }
+            else if (model.Kind == DeclKind.Union)
+            {
+                EmitUnionSerializer(sb, model);
+                sb.AppendLine();
+            }
         }
 
         sb.AppendLine("internal static class 
__ForyGeneratedModuleInitializer");
@@ -159,7 +180,7 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             else if (model.Kind == DeclKind.Union)
             {
                 sb.AppendLine(
-                    $"        
global::Apache.Fory.TypeResolver.RegisterGenerated<{model.TypeName}, 
global::Apache.Fory.UnionSerializer<{model.TypeName}>>();");
+                    $"        
global::Apache.Fory.TypeResolver.RegisterGenerated<{model.TypeName}, 
{model.SerializerName}>();");
             }
             else
             {
@@ -676,6 +697,284 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         sb.AppendLine("}");
     }
 
+    private static void EmitUnionSerializer(StringBuilder sb, TypeModel model)
+    {
+        sb.AppendLine(
+            $"file sealed class {model.SerializerName} : 
global::Apache.Fory.Serializer<{model.TypeName}>");
+        sb.AppendLine("{");
+        sb.AppendLine($"    public override {model.TypeName} DefaultValue => 
null!;");
+        sb.AppendLine();
+        sb.AppendLine("    private static global::Apache.Fory.RefMode 
__ForyRefMode(bool nullable, bool trackRef)");
+        sb.AppendLine("    {");
+        sb.AppendLine("        if (trackRef)");
+        sb.AppendLine("        {");
+        sb.AppendLine("            return 
global::Apache.Fory.RefMode.Tracking;");
+        sb.AppendLine("        }");
+        sb.AppendLine();
+        sb.AppendLine("        return nullable ? 
global::Apache.Fory.RefMode.NullOnly : global::Apache.Fory.RefMode.None;");
+        sb.AppendLine("    }");
+        sb.AppendLine();
+        foreach (UnionCaseModel unionCase in model.UnionCases.OrderBy(c => 
c.CaseId))
+        {
+            if (unionCase.ValueMember is { HasSchemaType: true } member)
+            {
+                EmitUnionCaseSerializer(sb, unionCase.CaseId, member);
+            }
+        }
+
+        sb.AppendLine(
+            $"    public override void 
WriteData(global::Apache.Fory.WriteContext context, in {model.TypeName} value, 
bool hasGenerics)");
+        sb.AppendLine("    {");
+        sb.AppendLine("        _ = hasGenerics;");
+        sb.AppendLine("        if (value is null)");
+        sb.AppendLine("        {");
+        sb.AppendLine("            throw new 
global::Apache.Fory.InvalidDataException(\"union value is null\");");
+        sb.AppendLine("        }");
+        sb.AppendLine();
+        sb.AppendLine("        switch (value)");
+        sb.AppendLine("        {");
+        UnionCaseModel? unknownCase = null;
+        foreach (UnionCaseModel unionCase in model.UnionCases.OrderBy(c => 
c.CaseId))
+        {
+            if (unionCase.IsUnknown)
+            {
+                unknownCase = unionCase;
+                sb.AppendLine($"            case {unionCase.TypeName} 
__foryCase:");
+                sb.AppendLine("            {");
+                sb.AppendLine("                if (__foryCase.CaseId < 0)");
+                sb.AppendLine("                {");
+                sb.AppendLine("                    throw new 
global::Apache.Fory.InvalidDataException($\"union case id out of range: 
{__foryCase.CaseId}\");");
+                sb.AppendLine("                }");
+                sb.AppendLine();
+                sb.AppendLine("                
context.Writer.WriteVarUInt32((uint)__foryCase.CaseId);");
+                sb.AppendLine("                
global::Apache.Fory.DynamicAnyCodec.WriteAny(context, __foryCase.Value, 
global::Apache.Fory.RefMode.Tracking, true, false);");
+                sb.AppendLine("                return;");
+                sb.AppendLine("            }");
+                continue;
+            }
+
+            sb.AppendLine($"            case {unionCase.TypeName} 
__foryCase:");
+            sb.AppendLine("            {");
+            sb.AppendLine($"                
context.Writer.WriteVarUInt32({unionCase.CaseId}u);");
+            EmitWriteUnionCasePayload(sb, unionCase, "__foryCase.Value", 4);
+            sb.AppendLine("                return;");
+            sb.AppendLine("            }");
+        }
+
+        sb.AppendLine("            default:");
+        sb.AppendLine("                throw new 
global::Apache.Fory.InvalidDataException($\"unsupported union case 
{value.GetType()}\");");
+        sb.AppendLine("        }");
+        sb.AppendLine("    }");
+        sb.AppendLine();
+        sb.AppendLine($"    public override {model.TypeName} 
ReadData(global::Apache.Fory.ReadContext context)");
+        sb.AppendLine("    {");
+        sb.AppendLine("        uint rawCaseId = 
context.Reader.ReadVarUInt32();");
+        sb.AppendLine("        if (rawCaseId > int.MaxValue)");
+        sb.AppendLine("        {");
+        sb.AppendLine("            throw new 
global::Apache.Fory.InvalidDataException($\"union case id out of range: 
{rawCaseId}\");");
+        sb.AppendLine("        }");
+        sb.AppendLine();
+        sb.AppendLine("        int caseId = (int)rawCaseId;");
+        sb.AppendLine("        switch (caseId)");
+        sb.AppendLine("        {");
+        foreach (UnionCaseModel unionCase in model.UnionCases.OrderBy(c => 
c.CaseId))
+        {
+            if (unionCase.IsUnknown)
+            {
+                sb.AppendLine("            case 0:");
+                sb.AppendLine("            {");
+                sb.AppendLine("                object? __foryUnknownValue = 
global::Apache.Fory.DynamicAnyCodec.ReadAny(context, 
global::Apache.Fory.RefMode.Tracking, true);");
+                sb.AppendLine($"                return new 
{unionCase.TypeName}(0, __foryUnknownValue);");
+                sb.AppendLine("            }");
+                continue;
+            }
+
+            string valueVar = $"__foryCaseValue{unionCase.CaseId}";
+            sb.AppendLine($"            case {unionCase.CaseId}:");
+            sb.AppendLine("            {");
+            EmitReadUnionCasePayload(sb, unionCase, valueVar, 4);
+            sb.AppendLine($"                return new 
{unionCase.TypeName}({valueVar});");
+            sb.AppendLine("            }");
+        }
+
+        sb.AppendLine("            default:");
+        sb.AppendLine("            {");
+        sb.AppendLine("                object? __foryUnknownValue = 
global::Apache.Fory.DynamicAnyCodec.ReadAny(context, 
global::Apache.Fory.RefMode.Tracking, true);");
+        if (unknownCase is null)
+        {
+            sb.AppendLine("                throw new 
global::Apache.Fory.InvalidDataException($\"unknown union case {caseId}\");");
+        }
+        else
+        {
+            sb.AppendLine($"                return new 
{unknownCase.TypeName}(caseId, __foryUnknownValue);");
+        }
+
+        sb.AppendLine("            }");
+        sb.AppendLine("        }");
+        sb.AppendLine("    }");
+        sb.AppendLine("}");
+    }
+
+    private static void EmitUnionCaseSerializer(
+        StringBuilder sb,
+        int caseId,
+        MemberModel member)
+    {
+        sb.AppendLine($"    private sealed class __ForyCaseSerializer{caseId} 
: global::Apache.Fory.Serializer<{member.TypeName}>");
+        sb.AppendLine("    {");
+        sb.AppendLine($"        internal static readonly 
__ForyCaseSerializer{caseId} Instance = new();");
+        sb.AppendLine();
+        sb.AppendLine($"        public override {member.TypeName} DefaultValue 
=> default!;");
+        sb.AppendLine();
+        sb.AppendLine($"        public override void 
WriteData(global::Apache.Fory.WriteContext context, in {member.TypeName} value, 
bool hasGenerics)");
+        sb.AppendLine("        {");
+        sb.AppendLine("            _ = hasGenerics;");
+        EmitWriteUnionTopType(sb, member.TypeMeta, 3);
+        string payloadExpr = member.IsNullableValueType ? 
"value.GetValueOrDefault()" : "value";
+        EmitWriteUnionPayload(sb, NonNullableMember(member), payloadExpr, 3);
+        sb.AppendLine("        }");
+        sb.AppendLine();
+        sb.AppendLine($"        public override {member.TypeName} 
ReadData(global::Apache.Fory.ReadContext context)");
+        sb.AppendLine("        {");
+        EmitValidateUnionTopType(sb, member.TypeMeta, 3);
+        EmitReadUnionPayload(sb, NonNullableMember(member), "__foryPayload", 
3);
+        sb.AppendLine("            return __foryPayload;");
+        sb.AppendLine("        }");
+        sb.AppendLine("    }");
+        sb.AppendLine();
+    }
+
+    private static void EmitWriteUnionCasePayload(
+        StringBuilder sb,
+        UnionCaseModel unionCase,
+        string valueExpr,
+        int indentLevel)
+    {
+        MemberModel member = unionCase.ValueMember!;
+        string indent = new(' ', indentLevel * 4);
+        string refModeExpr = BuildUnionCaseRefModeExpression(member);
+        string hasGenerics = member.IsCollection ? "true" : "false";
+
+        if (member.DynamicAnyKind == DynamicAnyKind.AnyValue)
+        {
+            sb.AppendLine(
+                
$"{indent}global::Apache.Fory.DynamicAnyCodec.WriteAny(context, {valueExpr}, 
{refModeExpr}, true, false);");
+            return;
+        }
+
+        if (!member.HasSchemaType)
+        {
+            sb.AppendLine(
+                
$"{indent}context.TypeResolver.GetSerializer<{member.TypeName}>().Write(context,
 {valueExpr}, {refModeExpr}, true, {hasGenerics});");
+            return;
+        }
+
+        sb.AppendLine(
+            
$"{indent}__ForyCaseSerializer{unionCase.CaseId}.Instance.Write(context, 
{valueExpr}, {refModeExpr}, false, false);");
+    }
+
+    private static void EmitReadUnionCasePayload(
+        StringBuilder sb,
+        UnionCaseModel unionCase,
+        string valueVar,
+        int indentLevel)
+    {
+        MemberModel member = unionCase.ValueMember!;
+        string indent = new(' ', indentLevel * 4);
+        string refModeExpr = BuildUnionCaseRefModeExpression(member);
+
+        if (member.DynamicAnyKind == DynamicAnyKind.AnyValue)
+        {
+            string typeOfTypeName = StripNullableForTypeOf(member.TypeName);
+            sb.AppendLine(
+                $"{indent}{member.TypeName} {valueVar} = 
({member.TypeName})global::Apache.Fory.DynamicAnyCodec.CastAnyDynamicValue(global::Apache.Fory.DynamicAnyCodec.ReadAny(context,
 {refModeExpr}, true), typeof({typeOfTypeName}))!;");
+            return;
+        }
+
+        if (!member.HasSchemaType)
+        {
+            sb.AppendLine(
+                $"{indent}{member.TypeName} {valueVar} = 
context.TypeResolver.GetSerializer<{member.TypeName}>().Read(context, 
{refModeExpr}, true);");
+            return;
+        }
+
+        sb.AppendLine(
+            $"{indent}{member.TypeName} {valueVar} = 
__ForyCaseSerializer{unionCase.CaseId}.Instance.Read(context, {refModeExpr}, 
false);");
+    }
+
+    private static void EmitWriteUnionPayload(
+        StringBuilder sb,
+        MemberModel member,
+        string valueExpr,
+        int indentLevel)
+    {
+        int id = 0;
+        if (member.FieldCodec is not null)
+        {
+            EmitWritePayload(sb, member.FieldCodec, valueExpr, indentLevel, 
ref id);
+            return;
+        }
+
+        if (TryBuildDirectPayloadWrite(member.Classification.TypeId, 
valueExpr, out string? writeCode))
+        {
+            string indent = new(' ', indentLevel * 4);
+            sb.AppendLine($"{indent}{writeCode}");
+            return;
+        }
+
+        string hasGenerics = member.IsCollection ? "true" : "false";
+        string fallbackIndent = new(' ', indentLevel * 4);
+        sb.AppendLine(
+            
$"{fallbackIndent}context.TypeResolver.GetSerializer<{member.TypeName}>().WriteData(context,
 {valueExpr}, {hasGenerics});");
+    }
+
+    private static void EmitReadUnionPayload(
+        StringBuilder sb,
+        MemberModel member,
+        string valueVar,
+        int indentLevel)
+    {
+        int id = 0;
+        if (member.FieldCodec is not null)
+        {
+            EmitReadPayload(sb, member.FieldCodec, valueVar, indentLevel, ref 
id);
+            return;
+        }
+
+        if (TryBuildDirectPayloadRead(member.Classification.TypeId, out 
string? readExpr))
+        {
+            string indent = new(' ', indentLevel * 4);
+            sb.AppendLine($"{indent}{member.TypeName} {valueVar} = 
{readExpr};");
+            return;
+        }
+
+        string fallbackIndent = new(' ', indentLevel * 4);
+        sb.AppendLine(
+            $"{fallbackIndent}{member.TypeName} {valueVar} = 
context.TypeResolver.GetSerializer<{member.TypeName}>().ReadData(context);");
+    }
+
+    private static void EmitWriteUnionTopType(
+        StringBuilder sb,
+        TypeMetaFieldTypeModel model,
+        int indentLevel)
+    {
+        string indent = new(' ', indentLevel * 4);
+        
sb.AppendLine($"{indent}context.Writer.WriteUInt8((byte)({model.TypeIdExpr}));");
+    }
+
+    private static void EmitValidateUnionTopType(
+        StringBuilder sb,
+        TypeMetaFieldTypeModel model,
+        int indentLevel)
+    {
+        string indent = new(' ', indentLevel * 4);
+        sb.AppendLine($"{indent}uint __foryTypeId = 
context.Reader.ReadUInt8();");
+        sb.AppendLine($"{indent}if (__foryTypeId != ({model.TypeIdExpr}))");
+        sb.AppendLine($"{indent}{{");
+        sb.AppendLine($"{indent}    throw new 
global::Apache.Fory.TypeMismatchException({model.TypeIdExpr}, __foryTypeId);");
+        sb.AppendLine($"{indent}}}");
+    }
+
     private static void EmitFieldCodecMethods(StringBuilder sb, MemberModel 
member)
     {
         FieldCodecModel codec = member.FieldCodec!;
@@ -1471,6 +1770,40 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             codec.Generics);
     }
 
+    private static MemberModel NonNullableMember(MemberModel member)
+    {
+        if (!member.IsNullable)
+        {
+            return member;
+        }
+
+        return new MemberModel(
+            member.Name,
+            member.FieldIdentifier,
+            member.OriginalIndex,
+            member.DeclKind,
+            member.IsNullableValueType && member.TypeName.EndsWith("?", 
StringComparison.Ordinal)
+                ? member.TypeName.Substring(0, member.TypeName.Length - 1)
+                : StripNullableForTypeOf(member.TypeName),
+            false,
+            false,
+            member.FieldId,
+            member.Classification,
+            member.Group,
+            member.IsCollection,
+            member.UseDictionaryTypeInfoCache,
+            member.IsRefType,
+            member.NeedsFieldTypeInfo,
+            member.DynamicAnyKind,
+            new TypeMetaFieldTypeModel(
+                member.TypeMeta.TypeIdExpr,
+                false,
+                member.TypeMeta.TrackRefByContext,
+                member.TypeMeta.Generics),
+            member.FieldCodec is null ? null : 
NonNullableCodec(member.FieldCodec),
+            member.HasSchemaType);
+    }
+
     private static string ElementTypeName(string arrayTypeName)
     {
         return arrayTypeName.EndsWith("[]", StringComparison.Ordinal)
@@ -2129,6 +2462,13 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         };
     }
 
+    private static string BuildUnionCaseRefModeExpression(MemberModel member)
+    {
+        return member.IsRefType
+            ? "__ForyRefMode(true, context.TrackRef)"
+            : "global::Apache.Fory.RefMode.NullOnly";
+    }
+
     private static string BuildFieldTypeInfoLiteral(MemberModel member)
     {
         return BoolLiteral(member.NeedsFieldTypeInfo);
@@ -2187,7 +2527,9 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
                 return null;
             }
 
-            if (!IsUnionType(typeSymbol))
+            List<Diagnostic> unionDiagnostics = [];
+            ImmutableArray<UnionCaseModel> unionCases = 
BuildUnionCases(typeSymbol, unionDiagnostics);
+            if (unionCases.IsEmpty)
             {
                 return new TypeModel(
                     typeName,
@@ -2207,7 +2549,8 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
                 DeclKind.Union,
                 ImmutableArray<MemberModel>.Empty,
                 ImmutableArray<MemberModel>.Empty,
-                ImmutableArray<Diagnostic>.Empty);
+                unionDiagnostics.ToImmutableArray(),
+                unionCases);
         }
 
         DeclKind kind = typeSymbol.TypeKind switch
@@ -2300,6 +2643,192 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         return new TypeModel(typeName, serializerName, kind, ordered, sorted, 
diagnostics.ToImmutableArray());
     }
 
+    private static ImmutableArray<UnionCaseModel> BuildUnionCases(
+        INamedTypeSymbol unionType,
+        List<Diagnostic> diagnostics)
+    {
+        List<UnionCaseModel> cases = [];
+        HashSet<int> caseIds = [];
+        foreach (INamedTypeSymbol caseType in unionType.GetTypeMembers())
+        {
+            if (!TryGetForyCase(caseType, diagnostics, out int caseId, out 
SchemaTypeModel? schemaType))
+            {
+                continue;
+            }
+
+            if (!caseIds.Add(caseId))
+            {
+                diagnostics.Add(Diagnostic.Create(
+                    DuplicateUnionCaseId,
+                    caseType.Locations.FirstOrDefault(),
+                    caseId,
+                    unionType.ToDisplayString(FullNameFormat)));
+                continue;
+            }
+
+            if (!SymbolEqualityComparer.Default.Equals(caseType.BaseType, 
unionType))
+            {
+                diagnostics.Add(Diagnostic.Create(
+                    InvalidUnionCase,
+                    caseType.Locations.FirstOrDefault(),
+                    caseType.ToDisplayString(FullNameFormat),
+                    "case type must directly derive from the annotated union 
root"));
+                continue;
+            }
+
+            string caseTypeName = caseType.ToDisplayString(FullNameFormat);
+            if (caseId == 0)
+            {
+                if (!HasProperty(caseType, "CaseId", SpecialType.System_Int32) 
||
+                    !HasObjectValueProperty(caseType))
+                {
+                    diagnostics.Add(Diagnostic.Create(
+                        InvalidUnionCase,
+                        caseType.Locations.FirstOrDefault(),
+                        caseTypeName,
+                        "case id 0 must expose CaseId:int and Value:object?"));
+                    continue;
+                }
+
+                cases.Add(new UnionCaseModel(caseId, caseTypeName, isUnknown: 
true, valueMember: null));
+                continue;
+            }
+
+            IPropertySymbol? valueProperty = FindProperty(caseType, "Value");
+            if (valueProperty is null)
+            {
+                diagnostics.Add(Diagnostic.Create(
+                    InvalidUnionCase,
+                    caseType.Locations.FirstOrDefault(),
+                    caseTypeName,
+                    "known cases must expose a Value property"));
+                continue;
+            }
+
+            MemberModel? valueMember = BuildMemberModel(
+                valueProperty.Name,
+                valueProperty.Type,
+                valueProperty,
+                MemberDeclKind.Property,
+                diagnostics,
+                schemaType,
+                parseFieldAttribute: false);
+            if (valueMember is null)
+            {
+                diagnostics.Add(Diagnostic.Create(
+                    InvalidUnionCase,
+                    valueProperty.Locations.FirstOrDefault(),
+                    caseTypeName,
+                    "case Value type is not supported"));
+                continue;
+            }
+
+            cases.Add(new UnionCaseModel(caseId, caseTypeName, isUnknown: 
false, valueMember));
+        }
+
+        if (!cases.Any(c => c.IsUnknown))
+        {
+            diagnostics.Add(Diagnostic.Create(
+                InvalidUnionCase,
+                unionType.Locations.FirstOrDefault(),
+                unionType.ToDisplayString(FullNameFormat),
+                "union must declare [ForyCase(0)] UnknownCase"));
+        }
+
+        return cases.OrderBy(c => c.CaseId).ToImmutableArray();
+    }
+
+    private static bool TryGetForyCase(
+        INamedTypeSymbol caseType,
+        List<Diagnostic> diagnostics,
+        out int caseId,
+        out SchemaTypeModel? schemaType)
+    {
+        caseId = default;
+        schemaType = null;
+        foreach (AttributeData attribute in caseType.GetAttributes())
+        {
+            string? attrName = attribute.AttributeClass?.ToDisplayString();
+            if (!string.Equals(attrName, "Apache.Fory.ForyCaseAttribute", 
StringComparison.Ordinal))
+            {
+                continue;
+            }
+
+            if (attribute.ConstructorArguments.Length != 1 ||
+                !TryGetUnionCaseId(attribute.ConstructorArguments[0], out 
caseId))
+            {
+                diagnostics.Add(Diagnostic.Create(
+                    InvalidUnionCase,
+                    caseType.Locations.FirstOrDefault(),
+                    caseType.ToDisplayString(FullNameFormat),
+                    "case id must be a non-negative int"));
+                return true;
+            }
+
+            foreach (KeyValuePair<string, TypedConstant> namedArg in 
attribute.NamedArguments)
+            {
+                if (!string.Equals(namedArg.Key, "Type", 
StringComparison.Ordinal))
+                {
+                    continue;
+                }
+
+                if (namedArg.Value.Value is not ITypeSymbol schemaSymbol ||
+                    TryParseSchemaType(schemaSymbol) is not SchemaTypeModel 
parsedSchema)
+                {
+                    diagnostics.Add(Diagnostic.Create(
+                        InvalidUnionCase,
+                        caseType.Locations.FirstOrDefault(),
+                        caseType.ToDisplayString(FullNameFormat),
+                        "ForyCase.Type must be an Apache.Fory.Schema.Types 
descriptor"));
+                    continue;
+                }
+
+                schemaType = parsedSchema;
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private static bool TryGetUnionCaseId(TypedConstant value, out int caseId)
+    {
+        caseId = default;
+        if (value.Value is int id && id >= 0)
+        {
+            caseId = id;
+            return true;
+        }
+
+        return false;
+    }
+
+    private static IPropertySymbol? FindProperty(INamedTypeSymbol type, string 
name)
+    {
+        foreach (ISymbol member in type.GetMembers(name))
+        {
+            if (member is IPropertySymbol property && !property.IsStatic)
+            {
+                return property;
+            }
+        }
+
+        return null;
+    }
+
+    private static bool HasProperty(INamedTypeSymbol type, string name, 
SpecialType specialType)
+    {
+        IPropertySymbol? property = FindProperty(type, name);
+        return property is not null && property.Type.SpecialType == 
specialType;
+    }
+
+    private static bool HasObjectValueProperty(INamedTypeSymbol type)
+    {
+        IPropertySymbol? property = FindProperty(type, "Value");
+        return property is not null && property.Type.SpecialType == 
SpecialType.System_Object;
+    }
+
     private static ForyAttributeKind GetForyAttributeKind(INamedTypeSymbol 
typeSymbol)
     {
         foreach (AttributeData attribute in typeSymbol.GetAttributes())
@@ -2330,44 +2859,66 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         ISymbol memberSymbol,
         MemberDeclKind memberDeclKind,
         List<Diagnostic> diagnostics)
+    {
+        return BuildMemberModel(
+            name,
+            memberType,
+            memberSymbol,
+            memberDeclKind,
+            diagnostics,
+            schemaTypeOverride: null,
+            parseFieldAttribute: true);
+    }
+
+    private static MemberModel? BuildMemberModel(
+        string name,
+        ITypeSymbol memberType,
+        ISymbol memberSymbol,
+        MemberDeclKind memberDeclKind,
+        List<Diagnostic> diagnostics,
+        SchemaTypeModel? schemaTypeOverride,
+        bool parseFieldAttribute)
     {
         (bool isOptional, ITypeSymbol unwrappedType) = 
UnwrapNullable(memberType);
         short? fieldId = null;
-        SchemaTypeModel? schemaType = null;
-        foreach (AttributeData attribute in memberSymbol.GetAttributes())
+        SchemaTypeModel? schemaType = schemaTypeOverride;
+        if (parseFieldAttribute)
         {
-            string? attrName = attribute.AttributeClass?.ToDisplayString();
-            if (!string.Equals(attrName, "Apache.Fory.ForyFieldAttribute", 
StringComparison.Ordinal))
+            foreach (AttributeData attribute in memberSymbol.GetAttributes())
             {
-                continue;
-            }
-
-            if (attribute.ConstructorArguments.Length == 1 &&
-                TryGetFieldId(attribute.ConstructorArguments[0], memberSymbol, 
diagnostics, out short ctorFieldId))
-            {
-                fieldId = ctorFieldId;
-            }
-
-            foreach (KeyValuePair<string, TypedConstant> namedArg in 
attribute.NamedArguments)
-            {
-                if (string.Equals(namedArg.Key, "Id", 
StringComparison.Ordinal))
+                string? attrName = attribute.AttributeClass?.ToDisplayString();
+                if (!string.Equals(attrName, "Apache.Fory.ForyFieldAttribute", 
StringComparison.Ordinal))
                 {
-                    if (TryGetFieldId(namedArg.Value, memberSymbol, 
diagnostics, out short parsedFieldId))
-                    {
-                        fieldId = parsedFieldId;
-                    }
-
                     continue;
                 }
 
-                if (!string.Equals(namedArg.Key, "Type", 
StringComparison.Ordinal))
+                if (attribute.ConstructorArguments.Length == 1 &&
+                    TryGetFieldId(attribute.ConstructorArguments[0], 
memberSymbol, diagnostics, out short ctorFieldId))
                 {
-                    continue;
+                    fieldId = ctorFieldId;
                 }
 
-                if (namedArg.Value.Value is ITypeSymbol schemaSymbol)
+                foreach (KeyValuePair<string, TypedConstant> namedArg in 
attribute.NamedArguments)
                 {
-                    schemaType = TryParseSchemaType(schemaSymbol);
+                    if (string.Equals(namedArg.Key, "Id", 
StringComparison.Ordinal))
+                    {
+                        if (TryGetFieldId(namedArg.Value, memberSymbol, 
diagnostics, out short parsedFieldId))
+                        {
+                            fieldId = parsedFieldId;
+                        }
+
+                        continue;
+                    }
+
+                    if (!string.Equals(namedArg.Key, "Type", 
StringComparison.Ordinal))
+                    {
+                        continue;
+                    }
+
+                    if (namedArg.Value.Value is ITypeSymbol schemaSymbol)
+                    {
+                        schemaType = TryParseSchemaType(schemaSymbol);
+                    }
                 }
             }
         }
@@ -2418,7 +2969,8 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             FieldNeedsTypeInfo(classification, dynamicAnyKind, unwrappedType),
             dynamicAnyKind == DynamicAnyKind.None ? DynamicAnyKind.None : 
dynamicAnyKind,
             typeMeta,
-            fieldCodec);
+            fieldCodec,
+            schemaType is not null);
     }
 
     private static TypeMetaFieldTypeModel BuildTypeMetaFieldTypeModel(
@@ -3323,6 +3875,12 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
 
     private static bool IsUnionType(ITypeSymbol symbol)
     {
+        if (symbol is INamedTypeSymbol namedType &&
+            GetForyAttributeKind(namedType) == ForyAttributeKind.Union)
+        {
+            return true;
+        }
+
         INamedTypeSymbol? current = symbol as INamedTypeSymbol;
         while (current is not null)
         {
@@ -3640,7 +4198,8 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             DeclKind kind,
             ImmutableArray<MemberModel> members,
             ImmutableArray<MemberModel> sortedMembers,
-            ImmutableArray<Diagnostic> diagnostics)
+            ImmutableArray<Diagnostic> diagnostics,
+            ImmutableArray<UnionCaseModel> unionCases = default)
         {
             TypeName = typeName;
             SerializerName = serializerName;
@@ -3648,6 +4207,9 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             Members = members;
             SortedMembers = sortedMembers;
             Diagnostics = diagnostics;
+            UnionCases = unionCases.IsDefault
+                ? ImmutableArray<UnionCaseModel>.Empty
+                : unionCases;
         }
 
         public string TypeName { get; }
@@ -3656,6 +4218,7 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         public ImmutableArray<MemberModel> Members { get; }
         public ImmutableArray<MemberModel> SortedMembers { get; }
         public ImmutableArray<Diagnostic> Diagnostics { get; }
+        public ImmutableArray<UnionCaseModel> UnionCases { get; }
     }
 
     private sealed class MemberModel
@@ -3677,7 +4240,8 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             bool needsFieldTypeInfo,
             DynamicAnyKind dynamicAnyKind,
             TypeMetaFieldTypeModel typeMeta,
-            FieldCodecModel? fieldCodec)
+            FieldCodecModel? fieldCodec,
+            bool hasSchemaType = false)
         {
             Name = name;
             FieldIdentifier = fieldIdentifier;
@@ -3696,6 +4260,7 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
             DynamicAnyKind = dynamicAnyKind;
             TypeMeta = typeMeta;
             FieldCodec = fieldCodec;
+            HasSchemaType = hasSchemaType;
         }
 
         public string Name { get; }
@@ -3715,6 +4280,23 @@ public sealed class ForyModelGenerator : 
IIncrementalGenerator
         public DynamicAnyKind DynamicAnyKind { get; }
         public TypeMetaFieldTypeModel TypeMeta { get; }
         public FieldCodecModel? FieldCodec { get; }
+        public bool HasSchemaType { get; }
+    }
+
+    private sealed class UnionCaseModel
+    {
+        public UnionCaseModel(int caseId, string typeName, bool isUnknown, 
MemberModel? valueMember)
+        {
+            CaseId = caseId;
+            TypeName = typeName;
+            IsUnknown = isUnknown;
+            ValueMember = valueMember;
+        }
+
+        public int CaseId { get; }
+        public string TypeName { get; }
+        public bool IsUnknown { get; }
+        public MemberModel? ValueMember { get; }
     }
 
     private enum MemberDeclKind
diff --git a/csharp/src/Fory/AnySerializer.cs b/csharp/src/Fory/AnySerializer.cs
index f6ff5ae69..63c6b9d17 100644
--- a/csharp/src/Fory/AnySerializer.cs
+++ b/csharp/src/Fory/AnySerializer.cs
@@ -118,7 +118,7 @@ public sealed class DynamicAnyObjectSerializer : 
Serializer<object?>
 
     private static bool AnyValueIsRefType(object value, TypeResolver 
typeResolver)
     {
-        TypeInfo typeInfo = typeResolver.GetTypeInfo(value.GetType());
+        TypeInfo typeInfo = 
typeResolver.GetTypeInfo(DynamicAnyCodec.ResolveRuntimeType(value));
         return typeInfo.IsRefType;
     }
 
@@ -169,10 +169,23 @@ public static class DynamicAnyCodec
             return;
         }
 
-        TypeInfo typeInfo = context.TypeResolver.GetTypeInfo(value.GetType());
+        TypeInfo typeInfo = 
context.TypeResolver.GetTypeInfo(ResolveRuntimeType(value));
         context.TypeResolver.WriteTypeInfo(typeInfo, context);
     }
 
+    internal static Type ResolveRuntimeType(object value)
+    {
+        Type runtimeType = value.GetType();
+        Type? baseType = runtimeType.BaseType;
+        if (baseType is not null &&
+            Attribute.IsDefined(baseType, typeof(ForyUnionAttribute), inherit: 
false))
+        {
+            return baseType;
+        }
+
+        return runtimeType;
+    }
+
     public static object? CastAnyDynamicValue(object? value, Type targetType)
     {
         if (value is null)
@@ -220,7 +233,7 @@ public static class DynamicAnyCodec
             return;
         }
 
-        TypeInfo typeInfo = context.TypeResolver.GetTypeInfo(value.GetType());
+        TypeInfo typeInfo = 
context.TypeResolver.GetTypeInfo(ResolveRuntimeType(value));
         context.TypeResolver.WriteDataObject(typeInfo, context, value, 
hasGenerics);
     }
 
diff --git a/csharp/src/Fory/Attributes.cs b/csharp/src/Fory/Attributes.cs
index ec3553c28..37f3e5275 100644
--- a/csharp/src/Fory/Attributes.cs
+++ b/csharp/src/Fory/Attributes.cs
@@ -38,13 +38,40 @@ public sealed class ForyEnumAttribute : Attribute
 }
 
 /// <summary>
-/// Marks a <see cref="Union"/> subclass as a generated Fory union type.
+/// Marks a generated Fory union type.
 /// </summary>
 [AttributeUsage(AttributeTargets.Class)]
 public sealed class ForyUnionAttribute : Attribute
 {
 }
 
+/// <summary>
+/// Marks a nested case type within a generated Fory union.
+/// </summary>
+[AttributeUsage(AttributeTargets.Class)]
+public sealed class ForyCaseAttribute : Attribute
+{
+    public ForyCaseAttribute(int id)
+    {
+        if (id < 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(id));
+        }
+
+        Id = id;
+    }
+
+    /// <summary>
+    /// Stable union case id written on the wire.
+    /// </summary>
+    public int Id { get; }
+
+    /// <summary>
+    /// Optional Fory schema descriptor type from 
<c>Apache.Fory.Schema.Types</c>.
+    /// </summary>
+    public Type? Type { get; set; }
+}
+
 /// <summary>
 /// Overrides generated serializer behavior for a field or property.
 /// </summary>
diff --git a/csharp/src/Fory/TypeInfo.cs b/csharp/src/Fory/TypeInfo.cs
index 65a040a21..99972bdb6 100644
--- a/csharp/src/Fory/TypeInfo.cs
+++ b/csharp/src/Fory/TypeInfo.cs
@@ -230,7 +230,8 @@ public sealed class TypeInfo
             return (null, Apache.Fory.UserTypeKind.Enum, false);
         }
 
-        if (typeof(Union).IsAssignableFrom(type))
+        if (type.GetCustomAttribute<ForyUnionAttribute>() is not null ||
+            typeof(Union).IsAssignableFrom(type))
         {
             return (null, Apache.Fory.UserTypeKind.TypedUnion, false);
         }
diff --git a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs 
b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
index efca15158..840af25cc 100644
--- a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
+++ b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
@@ -40,29 +40,24 @@ public sealed class ForyGeneratorTests
             }
 
             [ForyUnion]
-            public sealed class Choice : Union
+            public abstract partial record Choice
             {
-                private Choice(int index, object? value)
-                    : base(index, value)
+                private Choice()
                 {
                 }
 
-                public static Choice Of(int index, object? value)
-                {
-                    return new Choice(index, value);
-                }
+                [ForyCase(0)]
+                public sealed partial record UnknownCase(int CaseId, object? 
Value) : Choice;
 
-                public static Choice Text(string value)
-                {
-                    return new Choice(1, value);
-                }
+                [ForyCase(1)]
+                public sealed partial record Text(string Value) : Choice;
             }
 
             [ForyStruct]
             public sealed class Envelope
             {
                 public Status Status { get; set; }
-                public Choice Choice { get; set; } = Choice.Text(string.Empty);
+                public Choice Choice { get; set; } = new 
Choice.Text(string.Empty);
             }
             """;
 
diff --git a/csharp/tests/Fory.Tests/ForyRuntimeTests.cs 
b/csharp/tests/Fory.Tests/ForyRuntimeTests.cs
index 0fcee13b3..5006506b4 100644
--- a/csharp/tests/Fory.Tests/ForyRuntimeTests.cs
+++ b/csharp/tests/Fory.Tests/ForyRuntimeTests.cs
@@ -287,6 +287,29 @@ public sealed class StructWithUnion2
     public Union2<string, long> Union { get; set; } = Union2<string, 
long>.OfT1(string.Empty);
 }
 
+[ForyUnion]
+public abstract partial record SourceGeneratedShape
+{
+    private SourceGeneratedShape()
+    {
+    }
+
+    [ForyCase(0)]
+    public sealed partial record UnknownCase(int CaseId, object? Value) : 
SourceGeneratedShape;
+
+    [ForyCase(1)]
+    public sealed partial record Text(string Value) : SourceGeneratedShape;
+
+    [ForyCase(2, Type = typeof(S.Fixed<S.Int32>))]
+    public sealed partial record Number(int Value) : SourceGeneratedShape;
+}
+
+[ForyStruct]
+public sealed class SourceGeneratedUnionHolder
+{
+    public SourceGeneratedShape Shape { get; set; } = new 
SourceGeneratedShape.UnknownCase(0, null);
+}
+
 [ForyStruct]
 public sealed class DynamicAnyHolder
 {
@@ -1427,6 +1450,35 @@ public sealed class ForyRuntimeTests
         Assert.Throws<InvalidDataException>(() => { _ = 
reader.Deserialize<TwoStringField>(payload); });
     }
 
+    [Fact]
+    public void SourceGeneratedUnionRoundTrip()
+    {
+        ForyRuntime fory = ForyRuntime.Builder().Compatible(false).Build();
+        fory.Register<SourceGeneratedShape>(302);
+        fory.Register<SourceGeneratedUnionHolder>(303);
+
+        SourceGeneratedUnionHolder known = new()
+        {
+            Shape = new SourceGeneratedShape.Number(42),
+        };
+        SourceGeneratedUnionHolder unknown = new()
+        {
+            Shape = new SourceGeneratedShape.UnknownCase(99, "future"),
+        };
+
+        SourceGeneratedUnionHolder knownDecoded =
+            
fory.Deserialize<SourceGeneratedUnionHolder>(fory.Serialize(known));
+        SourceGeneratedUnionHolder unknownDecoded =
+            
fory.Deserialize<SourceGeneratedUnionHolder>(fory.Serialize(unknown));
+
+        SourceGeneratedShape.Number number = 
Assert.IsType<SourceGeneratedShape.Number>(knownDecoded.Shape);
+        Assert.Equal(42, number.Value);
+        SourceGeneratedShape.UnknownCase unknownCase =
+            
Assert.IsType<SourceGeneratedShape.UnknownCase>(unknownDecoded.Shape);
+        Assert.Equal(99, unknownCase.CaseId);
+        Assert.Equal("future", unknownCase.Value);
+    }
+
     [Fact]
     public void UnionFieldRoundTripCompatible()
     {
diff --git a/docs/compiler/generated-code.md b/docs/compiler/generated-code.md
index 3c9d8899f..089bf5ae3 100644
--- a/docs/compiler/generated-code.md
+++ b/docs/compiler/generated-code.md
@@ -787,16 +787,26 @@ public sealed partial class Person
 }
 ```
 
-Unions generate `[ForyUnion]` `Union` subclasses with typed case helpers:
+Unions generate `[ForyUnion]` ADTs. Case ID `0` is the unknown-case
+carrier; schema-defined cases use positive `[ForyCase]` IDs. If a case needs
+non-default schema encoding, the generated `[ForyCase]` carries `Type`. Known
+case record names are PascalCase FDL case names; payload types are emitted as
+qualified references when needed to avoid name conflicts.
 
 ```csharp
 [ForyUnion]
-public sealed class Animal : Union
+public abstract partial record Animal
 {
-    public static Animal Dog(Dog value) { ... }
-    public static Animal Cat(Cat value) { ... }
-    public bool IsDog => ...;
-    public Dog DogValue() { ... }
+    private Animal() {}
+
+    [ForyCase(0)]
+    public sealed partial record UnknownCase(int CaseId, object? Value) : 
Animal;
+
+    [ForyCase(1)]
+    public sealed partial record Dog(global::addressbook.Dog Value) : Animal;
+
+    [ForyCase(2)]
+    public sealed partial record Cat(global::addressbook.Cat Value) : Animal;
 }
 ```
 
diff --git a/docs/guide/csharp/schema-metadata.md 
b/docs/guide/csharp/schema-metadata.md
index 37ebd33a7..52ae4cea8 100644
--- a/docs/guide/csharp/schema-metadata.md
+++ b/docs/guide/csharp/schema-metadata.md
@@ -19,7 +19,7 @@ license: |
   limitations under the License.
 ---
 
-This page covers field-level serializer configuration for C# generated 
serializers.
+This page covers schema metadata for C# generated serializers.
 
 ## `[ForyStruct]` and `[ForyField]`
 
@@ -81,6 +81,33 @@ Dense array fields use `S.Array<TElement>`, for example 
`S.Array<S.Int32>` or `S
 
 Nullability comes from the C# carrier type. Use `List<ulong?>` for nullable 
list elements and `NullableKeyDictionary<TKey, TValue>` when a map needs 
nullable keys.
 
+## `[ForyUnion]` and `[ForyCase]`
+
+Generated union cases use `[ForyCase]` for both the stable case ID and optional
+case payload schema type. Do not put `[ForyField]` on union case payload
+members. Known case record names use PascalCase FDL case names; payload types
+use qualified references when needed to avoid name conflicts.
+
+```csharp
+using Apache.Fory;
+using S = Apache.Fory.Schema.Types;
+
+[ForyUnion]
+public abstract partial record Shape
+{
+    private Shape() {}
+
+    [ForyCase(0)]
+    public sealed partial record UnknownCase(int CaseId, object? Value) : 
Shape;
+
+    [ForyCase(1)]
+    public sealed partial record Circle(global::example.Circle Value) : Shape;
+
+    [ForyCase(2, Type = typeof(S.Fixed<S.Int32>))]
+    public sealed partial record Code(int Value) : Shape;
+}
+```
+
 ## Nullability and Reference Tracking
 
 - Field nullability comes from C# type nullability (`string?`, nullable value 
types, etc.).
diff --git a/docs/guide/csharp/supported-types.md 
b/docs/guide/csharp/supported-types.md
index e93b59221..3148694ad 100644
--- a/docs/guide/csharp/supported-types.md
+++ b/docs/guide/csharp/supported-types.md
@@ -76,7 +76,7 @@ This page summarizes built-in and generated type support in 
Apache Fory™ C#.
 
 ## User Types
 
-- `[ForyStruct]` classes/structs via source-generated serializers, plus 
`[ForyEnum]` enums and `[ForyUnion]` union subclasses
+- `[ForyStruct]` classes/structs via source-generated serializers, plus 
`[ForyEnum]` enums and `[ForyUnion]` ADT records
 - Custom serializer types registered through `Register<T, TSerializer>(...)`
 - `Union` / `Union2<...>` typed union support
 
diff --git a/docs/specification/xlang_type_mapping.md 
b/docs/specification/xlang_type_mapping.md
index 9894bbb1f..ba29e27d6 100644
--- a/docs/specification/xlang_type_mapping.md
+++ b/docs/specification/xlang_type_mapping.md
@@ -88,7 +88,7 @@ FDL spells them as an encoding modifier plus a semantic 
integer type.
 | named_compatible_struct            | 30           | pojo/record              
                 | data class                                | object           
                     | struct/class                                        | 
struct                                         | struct                         
   | `[ForyStruct]` class/struct        | @ForyStruct struct/class | 
@ForyStruct class           | case class/class                | data 
class/class       |
 | ext                                | 31           | pojo/record              
                 | data class                                | object           
                     | struct/class                                        | 
struct                                         | struct                         
   | `[ForyStruct]` class/struct        | @ForyStruct struct/class | 
@ForyStruct class           | case class/class                | data 
class/class       |
 | named_ext                          | 32           | pojo/record              
                 | data class                                | object           
                     | struct/class                                        | 
struct                                         | struct                         
   | `[ForyStruct]` class/struct        | @ForyStruct struct/class | 
@ForyStruct class           | case class/class                | data 
class/class       |
-| union                              | 33           | Union                    
                 | typing.Union                              | /                
                     | `std::variant<Ts...>`                               | /  
                                            | tagged union enum                 
| `[ForyUnion]` Union subclass       | tagged enum              | @ForyUnion 
class            | ADT enum                        | sealed class           |
+| union                              | 33           | Union                    
                 | typing.Union                              | /                
                     | `std::variant<Ts...>`                               | /  
                                            | tagged union enum                 
| `[ForyUnion]` ADT record           | tagged enum              | @ForyUnion 
class            | ADT enum                        | sealed class           |
 | none                               | 36           | null                     
                 | None                                      | null             
                     | `std::monostate`                                    | 
nil                                            | `()`                           
   | null                               | nil                      | null       
                 | null                            | null                   |
 | duration                           | 37           | Duration                 
                 | timedelta                                 | Number           
                     | duration                                            | 
Duration                                       | Duration                       
   | TimeSpan                           | Duration                 | Duration   
                 | java.time.Duration              | kotlin.time.Duration   |
 | timestamp                          | 38           | Instant                  
                 | datetime                                  | Number           
                     | std::chrono::nanoseconds                            | 
Time                                           | Timestamp                      
   | DateTime/DateTimeOffset            | Date                     | Timestamp  
                 | java.time.Instant               | java.time.Instant      |
diff --git a/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs 
b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
index 92b5bb089..f292ae05f 100644
--- a/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
+++ b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
@@ -47,14 +47,15 @@ public sealed class RoundtripTests
         auto_id.AutoIdForyRegistration.Register(fory);
 
         auto_id.Envelope envelope = BuildEnvelope();
-        auto_id.Wrapper wrapper = auto_id.Wrapper.Envelope(envelope);
+        auto_id.Wrapper wrapper = new auto_id.Wrapper.Envelope(envelope);
 
         auto_id.Envelope envelopeDecoded = 
fory.Deserialize<auto_id.Envelope>(fory.Serialize(envelope));
         AssertEnvelope(envelope, envelopeDecoded);
 
         auto_id.Wrapper wrapperDecoded = 
fory.Deserialize<auto_id.Wrapper>(fory.Serialize(wrapper));
-        Assert.True(wrapperDecoded.IsEnvelope);
-        AssertEnvelope(envelope, wrapperDecoded.EnvelopeValue());
+        auto_id.Wrapper.Envelope wrapperEnvelope =
+            Assert.IsType<auto_id.Wrapper.Envelope>(wrapperDecoded);
+        AssertEnvelope(envelope, wrapperEnvelope.Value);
 
         RoundTripFile(fory, "DATA_FILE_AUTO_ID", envelope, AssertEnvelope);
     }
@@ -344,15 +345,15 @@ public sealed class RoundtripTests
         addressbook.AddressBook decodedBook = 
addressbook.AddressBook.FromBytes(book.ToBytes());
         AssertAddressBook(book, decodedBook);
 
-        addressbook.Animal animal = addressbook.Animal.Dog(new addressbook.Dog
+        addressbook.Animal animal = new addressbook.Animal.Dog(new 
addressbook.Dog
         {
             Name = "Rex",
             BarkVolume = 5,
         });
         addressbook.Animal decodedAnimal = 
addressbook.Animal.FromBytes(animal.ToBytes());
-        Assert.True(decodedAnimal.IsDog);
-        Assert.Equal("Rex", decodedAnimal.DogValue().Name);
-        Assert.Equal(5, decodedAnimal.DogValue().BarkVolume);
+        addressbook.Animal.Dog dog = 
Assert.IsType<addressbook.Animal.Dog>(decodedAnimal);
+        Assert.Equal("Rex", dog.Value.Name);
+        Assert.Equal(5, dog.Value.BarkVolume);
     }
 
     private static ForyRuntime BuildFory(bool compatible, bool trackRef)
@@ -414,12 +415,12 @@ public sealed class RoundtripTests
             PhoneType = addressbook.Person.PhoneType.Work,
         };
 
-        addressbook.Animal pet = addressbook.Animal.Dog(new addressbook.Dog
+        addressbook.Animal pet = new addressbook.Animal.Dog(new addressbook.Dog
         {
             Name = "Rex",
             BarkVolume = 5,
         });
-        pet = addressbook.Animal.Cat(new addressbook.Cat
+        pet = new addressbook.Animal.Cat(new addressbook.Cat
         {
             Name = "Mimi",
             Lives = 9,
@@ -462,7 +463,7 @@ public sealed class RoundtripTests
         {
             Id = "env-1",
             PayloadValue = payload,
-            DetailValue = auto_id.Envelope.Detail.Payload(payload),
+            DetailValue = new auto_id.Envelope.Detail.Payload(payload),
             Status = auto_id.Status.Ok,
         };
     }
@@ -488,7 +489,7 @@ public sealed class RoundtripTests
             TaggedU64Value = 2222222222,
             Float32Value = 2.5f,
             Float64Value = 3.5,
-            ContactValue = complex_pb.PrimitiveTypes.Contact.Phone(12345),
+            ContactValue = new complex_pb.PrimitiveTypes.Contact.Phone(12345),
         };
     }
 
@@ -511,7 +512,7 @@ public sealed class RoundtripTests
 
     private static collection.NumericCollectionUnion 
BuildNumericCollectionUnion()
     {
-        return collection.NumericCollectionUnion.Int32Values([7, 8, 9]);
+        return new collection.NumericCollectionUnion.Int32Values([7, 8, 9]);
     }
 
     private static collection.NumericCollectionsArray 
BuildNumericCollectionsArray()
@@ -533,7 +534,7 @@ public sealed class RoundtripTests
 
     private static collection.NumericCollectionArrayUnion 
BuildNumericCollectionArrayUnion()
     {
-        return collection.NumericCollectionArrayUnion.Uint16Values([1000, 
2000, 3000]);
+        return new collection.NumericCollectionArrayUnion.Uint16Values([1000, 
2000, 3000]);
     }
 
     private static example.ExampleMessage BuildExampleMessage()
@@ -548,7 +549,7 @@ public sealed class RoundtripTests
             Label = "other",
             Count = 8,
         };
-        example.ExampleLeafUnion leafUnion = 
example.ExampleLeafUnion.Leaf(otherLeaf);
+        example.ExampleLeafUnion leafUnion = new 
example.ExampleLeafUnion.Leaf(otherLeaf);
 
         return new example.ExampleMessage
         {
@@ -622,7 +623,7 @@ public sealed class RoundtripTests
             DecimalList = [1.25m, 2.50m],
             EnumList = [example.ExampleState.Unknown, 
example.ExampleState.Failed],
             MessageList = [leaf, otherLeaf],
-            UnionList = [example.ExampleLeafUnion.Note("note"), leafUnion],
+            UnionList = [new example.ExampleLeafUnion.Note("note"), leafUnion],
             MaybeFixedI32List = [1, null, 3],
             MaybeUint64List = [10, null, 30],
             BoolArray = [true, false],
@@ -681,7 +682,7 @@ public sealed class RoundtripTests
             MessageValuesByName = new Dictionary<string, example.ExampleLeaf> 
{ ["leaf"] = leaf },
             UnionValuesByName = new Dictionary<string, 
example.ExampleLeafUnion>
             {
-                ["union"] = example.ExampleLeafUnion.Code(42),
+                ["union"] = new example.ExampleLeafUnion.Code(42),
             },
             Uint8ArrayValuesByName = new Dictionary<string, byte[]>
             {
@@ -730,12 +731,12 @@ public sealed class RoundtripTests
 
     private static example.ExampleMessageUnion BuildExampleMessageUnion()
     {
-        return example.ExampleMessageUnion.Int32ArrayList([[11, 12], [13, 
14]]);
+        return new example.ExampleMessageUnion.Int32ArrayList([[11, 12], [13, 
14]]);
     }
 
     private static example.ExampleMessageUnion BuildExampleArrayListUnion()
     {
-        return example.ExampleMessageUnion.Uint8ArrayList([[4, 5], [6]]);
+        return new example.ExampleMessageUnion.Uint8ArrayList([[4, 5], [6]]);
     }
 
     private static optional_types.OptionalHolder BuildOptionalHolder()
@@ -781,7 +782,7 @@ public sealed class RoundtripTests
         return new optional_types.OptionalHolder
         {
             AllTypes = all,
-            Choice = optional_types.OptionalUnion.Note("optional"),
+            Choice = new optional_types.OptionalUnion.Note("optional"),
         };
     }
 
@@ -797,7 +798,7 @@ public sealed class RoundtripTests
             {
                 Name = "inner",
             },
-            UnionValue = any_example.AnyUnion.Text("union"),
+            UnionValue = new any_example.AnyUnion.Text("union"),
             ListValue = new List<string>
             {
                 "alpha",
@@ -815,7 +816,7 @@ public sealed class RoundtripTests
     {
         any_example_pb.AnyUnion union = new()
         {
-            KindValue = any_example_pb.AnyUnion.Kind.Text("proto-union"),
+            KindValue = new any_example_pb.AnyUnion.Kind.Text("proto-union"),
         };
 
         return new any_example_pb.AnyHolder
@@ -885,7 +886,7 @@ public sealed class RoundtripTests
             },
             Names = ["alpha", "beta"],
             Flags = [true, false],
-            Payload = complex_fbs.Payload.Metric(new complex_fbs.Metric
+            Payload = new complex_fbs.Payload.Metric(new complex_fbs.Metric
             {
                 Value = 42.0,
             }),
@@ -986,9 +987,9 @@ public sealed class RoundtripTests
         Assert.Equal(expectedPerson.Phones.Count, actualPerson.Phones.Count);
         Assert.Equal(expectedPerson.Phones[0].Number, 
actualPerson.Phones[0].Number);
         Assert.Equal(expectedPerson.Phones[0].PhoneType, 
actualPerson.Phones[0].PhoneType);
-        Assert.True(actualPerson.Pet.IsCat);
-        Assert.Equal("Mimi", actualPerson.Pet.CatValue().Name);
-        Assert.Equal(9, actualPerson.Pet.CatValue().Lives);
+        addressbook.Animal.Cat cat = 
Assert.IsType<addressbook.Animal.Cat>(actualPerson.Pet);
+        Assert.Equal("Mimi", cat.Value.Name);
+        Assert.Equal(9, cat.Value.Lives);
     }
 
     private static void AssertEnvelope(auto_id.Envelope expected, 
auto_id.Envelope actual)
@@ -1000,8 +1001,11 @@ public sealed class RoundtripTests
         Assert.Equal(expected.Status, actual.Status);
 
         Assert.NotNull(actual.DetailValue);
-        Assert.True(actual.DetailValue.IsPayload);
-        Assert.Equal(expected.DetailValue.PayloadValue().Value, 
actual.DetailValue.PayloadValue().Value);
+        auto_id.Envelope.Detail.Payload expectedPayload =
+            
Assert.IsType<auto_id.Envelope.Detail.Payload>(expected.DetailValue);
+        auto_id.Envelope.Detail.Payload actualPayload =
+            Assert.IsType<auto_id.Envelope.Detail.Payload>(actual.DetailValue);
+        Assert.Equal(expectedPayload.Value.Value, actualPayload.Value.Value);
     }
 
     private static void AssertPrimitiveTypes(complex_pb.PrimitiveTypes 
expected, complex_pb.PrimitiveTypes actual)
@@ -1026,9 +1030,11 @@ public sealed class RoundtripTests
 
         Assert.NotNull(expected.ContactValue);
         Assert.NotNull(actual.ContactValue);
-        Assert.Equal(expected.ContactValue.CaseId(), 
actual.ContactValue.CaseId());
-        Assert.True(actual.ContactValue.IsPhone);
-        Assert.Equal(expected.ContactValue.PhoneValue(), 
actual.ContactValue.PhoneValue());
+        complex_pb.PrimitiveTypes.Contact.Phone expectedPhone =
+            
Assert.IsType<complex_pb.PrimitiveTypes.Contact.Phone>(expected.ContactValue);
+        complex_pb.PrimitiveTypes.Contact.Phone actualPhone =
+            
Assert.IsType<complex_pb.PrimitiveTypes.Contact.Phone>(actual.ContactValue);
+        Assert.Equal(expectedPhone.Value, actualPhone.Value);
     }
 
     private static void AssertNumericCollections(
@@ -1051,9 +1057,11 @@ public sealed class RoundtripTests
         collection.NumericCollectionUnion expected,
         collection.NumericCollectionUnion actual)
     {
-        Assert.Equal(expected.CaseId(), actual.CaseId());
-        Assert.True(actual.IsInt32Values);
-        Assert.Equal(expected.Int32ValuesValue(), actual.Int32ValuesValue());
+        collection.NumericCollectionUnion.Int32Values expectedValues =
+            
Assert.IsType<collection.NumericCollectionUnion.Int32Values>(expected);
+        collection.NumericCollectionUnion.Int32Values actualValues =
+            
Assert.IsType<collection.NumericCollectionUnion.Int32Values>(actual);
+        Assert.Equal(expectedValues.Value, actualValues.Value);
     }
 
     private static void AssertNumericCollectionsArray(
@@ -1076,9 +1084,11 @@ public sealed class RoundtripTests
         collection.NumericCollectionArrayUnion expected,
         collection.NumericCollectionArrayUnion actual)
     {
-        Assert.Equal(expected.CaseId(), actual.CaseId());
-        Assert.True(actual.IsUint16Values);
-        Assert.Equal(expected.Uint16ValuesValue(), actual.Uint16ValuesValue());
+        collection.NumericCollectionArrayUnion.Uint16Values expectedValues =
+            
Assert.IsType<collection.NumericCollectionArrayUnion.Uint16Values>(expected);
+        collection.NumericCollectionArrayUnion.Uint16Values actualValues =
+            
Assert.IsType<collection.NumericCollectionArrayUnion.Uint16Values>(actual);
+        Assert.Equal(expectedValues.Value, actualValues.Value);
     }
 
     private static void AssertExampleMessage(example.ExampleMessage expected, 
example.ExampleMessage actual)
@@ -1173,23 +1183,25 @@ public sealed class RoundtripTests
     {
         Assert.NotNull(expected);
         Assert.NotNull(actual);
-        Assert.Equal(expected.CaseId(), actual.CaseId());
-        switch (expected.Case())
+        switch (expected)
         {
-            case example.ExampleLeafUnion.ExampleLeafUnionCase.Note:
-                Assert.True(actual.IsNote);
-                Assert.Equal(expected.NoteValue(), actual.NoteValue());
+            case example.ExampleLeafUnion.Note expectedNote:
+                example.ExampleLeafUnion.Note actualNote =
+                    Assert.IsType<example.ExampleLeafUnion.Note>(actual);
+                Assert.Equal(expectedNote.Value, actualNote.Value);
                 break;
-            case example.ExampleLeafUnion.ExampleLeafUnionCase.Code:
-                Assert.True(actual.IsCode);
-                Assert.Equal(expected.CodeValue(), actual.CodeValue());
+            case example.ExampleLeafUnion.Code expectedCode:
+                example.ExampleLeafUnion.Code actualCode =
+                    Assert.IsType<example.ExampleLeafUnion.Code>(actual);
+                Assert.Equal(expectedCode.Value, actualCode.Value);
                 break;
-            case example.ExampleLeafUnion.ExampleLeafUnionCase.Leaf:
-                Assert.True(actual.IsLeaf);
-                AssertExampleLeaf(expected.LeafValue(), actual.LeafValue());
+            case example.ExampleLeafUnion.Leaf expectedLeaf:
+                example.ExampleLeafUnion.Leaf actualLeaf =
+                    Assert.IsType<example.ExampleLeafUnion.Leaf>(actual);
+                AssertExampleLeaf(expectedLeaf.Value, actualLeaf.Value);
                 break;
             default:
-                Assert.Fail($"Unexpected ExampleLeafUnion case 
{expected.CaseId()}");
+                Assert.Fail($"Unexpected ExampleLeafUnion case 
{expected.GetType()}");
                 break;
         }
     }
@@ -1200,27 +1212,30 @@ public sealed class RoundtripTests
     {
         Assert.NotNull(expected);
         Assert.NotNull(actual);
-        Assert.Equal(expected.CaseId(), actual.CaseId());
-        switch (expected.Case())
+        switch (expected)
         {
-            case 
example.ExampleMessageUnion.ExampleMessageUnionCase.Int32ArrayList:
-                Assert.True(actual.IsInt32ArrayList);
-                AssertArrayList(expected.Int32ArrayListValue(), 
actual.Int32ArrayListValue());
+            case example.ExampleMessageUnion.Int32ArrayList expectedIntList:
+                example.ExampleMessageUnion.Int32ArrayList actualIntList =
+                    
Assert.IsType<example.ExampleMessageUnion.Int32ArrayList>(actual);
+                AssertArrayList(expectedIntList.Value, actualIntList.Value);
                 break;
-            case 
example.ExampleMessageUnion.ExampleMessageUnionCase.Uint8ArrayList:
-                Assert.True(actual.IsUint8ArrayList);
-                AssertArrayList(expected.Uint8ArrayListValue(), 
actual.Uint8ArrayListValue());
+            case example.ExampleMessageUnion.Uint8ArrayList expectedByteList:
+                example.ExampleMessageUnion.Uint8ArrayList actualByteList =
+                    
Assert.IsType<example.ExampleMessageUnion.Uint8ArrayList>(actual);
+                AssertArrayList(expectedByteList.Value, actualByteList.Value);
                 break;
-            case 
example.ExampleMessageUnion.ExampleMessageUnionCase.Uint8ArrayValuesByName:
-                Assert.True(actual.IsUint8ArrayValuesByName);
-                AssertArrayMap(expected.Uint8ArrayValuesByNameValue(), 
actual.Uint8ArrayValuesByNameValue());
+            case example.ExampleMessageUnion.Uint8ArrayValuesByName 
expectedByteMap:
+                example.ExampleMessageUnion.Uint8ArrayValuesByName 
actualByteMap =
+                    
Assert.IsType<example.ExampleMessageUnion.Uint8ArrayValuesByName>(actual);
+                AssertArrayMap(expectedByteMap.Value, actualByteMap.Value);
                 break;
-            case 
example.ExampleMessageUnion.ExampleMessageUnionCase.Int32ArrayValuesByName:
-                Assert.True(actual.IsInt32ArrayValuesByName);
-                AssertArrayMap(expected.Int32ArrayValuesByNameValue(), 
actual.Int32ArrayValuesByNameValue());
+            case example.ExampleMessageUnion.Int32ArrayValuesByName 
expectedIntMap:
+                example.ExampleMessageUnion.Int32ArrayValuesByName 
actualIntMap =
+                    
Assert.IsType<example.ExampleMessageUnion.Int32ArrayValuesByName>(actual);
+                AssertArrayMap(expectedIntMap.Value, actualIntMap.Value);
                 break;
             default:
-                Assert.Fail($"Unexpected ExampleMessageUnion case 
{expected.CaseId()}");
+                Assert.Fail($"Unexpected ExampleMessageUnion case 
{expected.GetType()}");
                 break;
         }
     }
@@ -1266,9 +1281,11 @@ public sealed class RoundtripTests
 
         Assert.NotNull(actual.Choice);
         Assert.NotNull(expected.Choice);
-        Assert.Equal(expected.Choice.CaseId(), actual.Choice.CaseId());
-        Assert.True(actual.Choice.IsNote);
-        Assert.Equal(expected.Choice.NoteValue(), actual.Choice.NoteValue());
+        optional_types.OptionalUnion.Note expectedChoice =
+            Assert.IsType<optional_types.OptionalUnion.Note>(expected.Choice);
+        optional_types.OptionalUnion.Note actualChoice =
+            Assert.IsType<optional_types.OptionalUnion.Note>(actual.Choice);
+        Assert.Equal(expectedChoice.Value, actualChoice.Value);
     }
 
     private static void AssertAnyHolder(any_example.AnyHolder expected, 
any_example.AnyHolder actual)
@@ -1293,11 +1310,11 @@ public sealed class RoundtripTests
         Assert.True(TryAnyInnerName(actual.MessageValue, out string? 
actualInnerName));
         Assert.Equal(expectedInnerName, actualInnerName);
 
-        any_example.AnyUnion actualUnion = 
Assert.IsType<any_example.AnyUnion>(actual.UnionValue);
-        any_example.AnyUnion expectedUnion = 
Assert.IsType<any_example.AnyUnion>(expected.UnionValue);
-        Assert.Equal(expectedUnion.CaseId(), actualUnion.CaseId());
-        Assert.True(actualUnion.IsText);
-        Assert.Equal(expectedUnion.TextValue(), actualUnion.TextValue());
+        any_example.AnyUnion.Text actualUnion =
+            Assert.IsType<any_example.AnyUnion.Text>(actual.UnionValue);
+        any_example.AnyUnion.Text expectedUnion =
+            Assert.IsType<any_example.AnyUnion.Text>(expected.UnionValue);
+        Assert.Equal(expectedUnion.Value, actualUnion.Value);
 
         Assert.True(TryStringList(expected.ListValue, out List<string> 
expectedList));
         Assert.True(TryStringList(actual.ListValue, out List<string> 
actualList));
@@ -1334,9 +1351,11 @@ public sealed class RoundtripTests
         any_example_pb.AnyUnion actualUnion = 
Assert.IsType<any_example_pb.AnyUnion>(actual.UnionValue);
         Assert.NotNull(expectedUnion.KindValue);
         Assert.NotNull(actualUnion.KindValue);
-        Assert.Equal(expectedUnion.KindValue!.CaseId(), 
actualUnion.KindValue!.CaseId());
-        Assert.True(actualUnion.KindValue.IsText);
-        Assert.Equal(expectedUnion.KindValue.TextValue(), 
actualUnion.KindValue.TextValue());
+        any_example_pb.AnyUnion.Kind.Text expectedKind =
+            
Assert.IsType<any_example_pb.AnyUnion.Kind.Text>(expectedUnion.KindValue);
+        any_example_pb.AnyUnion.Kind.Text actualKind =
+            
Assert.IsType<any_example_pb.AnyUnion.Kind.Text>(actualUnion.KindValue);
+        Assert.Equal(expectedKind.Value, actualKind.Value);
 
         Assert.True(TryStringList(expected.ListValue, out List<string> 
expectedList));
         Assert.True(TryStringList(actual.ListValue, out List<string> 
actualList));
@@ -1386,9 +1405,11 @@ public sealed class RoundtripTests
 
         Assert.NotNull(expected.Payload);
         Assert.NotNull(actual.Payload);
-        Assert.Equal(expected.Payload.CaseId(), actual.Payload.CaseId());
-        Assert.True(actual.Payload.IsMetric);
-        Assert.Equal(expected.Payload.MetricValue().Value, 
actual.Payload.MetricValue().Value);
+        complex_fbs.Payload.Metric expectedMetric =
+            Assert.IsType<complex_fbs.Payload.Metric>(expected.Payload);
+        complex_fbs.Payload.Metric actualMetric =
+            Assert.IsType<complex_fbs.Payload.Metric>(actual.Payload);
+        Assert.Equal(expectedMetric.Value.Value, actualMetric.Value.Value);
     }
 
     private static void AssertTree(tree.TreeNode root)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to