Skip to content

EPC42: cover union types (requires a Roslyn update) #331

Description

@SergeyTeplyakov

Summary

EPC42 (DataContractSerializableMemberAnalyzer) does not report [DataMember]s whose type is a C# union. This is a deliberate gap: the analyzer builds against Microsoft.CodeAnalysis.CSharp 4.13.0, which cannot parse the union syntax, so the feature cannot be tested end-to-end today.

Why this matters

Unions are a worse failure mode than the IPAddress case EPC42 already covers. Instead of throwing, DataContractSerializer silently drops the payload.

Verified empirically with SDK 11.0.100-preview.6.26359.118 targeting net11.0:

public record Circle(double Radius);
public record Square(double Side);
public union Shape(Circle, Square);   // case types go in the header list

[DataContract]
public class Config
{
    [DataMember]
    public Shape Value { get; set; }
}

var s = new DataContractSerializer(typeof(Config));
s.WriteObject(stream, new Config { Value = new Circle(42.0) });

Output — no exception, and the Circle is gone:

<Config xmlns="http://schemas.datacontract.org/2004/07/" xmlns:i="http://www.w3.org/2001/XMLSchema-instance"><Value/></Config>

Round-tripping through ReadObject returns an empty union.

Root cause

Reflection over a compiled union shows the shape the compiler emits:

Aspect Value
Kind sealed struct (BaseType = System.ValueType)
Interfaces System.Runtime.CompilerServices.IUnion
Attributes System.Runtime.CompilerServices.UnionAttribute
Constructors one per case type — (Circle), (Square); no parameterless one
State a private field <Value>k__BackingField of type object
Public surface a get-only Value property of type object

DataContractSerializer's POCO contract serializes public read/write properties and public fields only. A union exposes neither, so it has zero serializable members — hence the empty element and the silent data loss.

Note also that EPC42's existing checks cannot catch this incidentally: a union is a public value type, so it satisfies both "is public" and "has a parameterless constructor".

Proposed implementation

In DataContractSerializableMemberAnalyzer.IsDataContractSerializable, after the existing [DataContract] / [CollectionDataContract] / IsSerializable / ISerializable / IXmlSerializable checks, report the member when its type is a union:

private static bool IsUnion(INamedTypeSymbol type, SerializationTypes knownTypes)
{
    // Both are checked so that detection does not depend on a single marker.
    return HasAttribute(type, knownTypes.UnionAttribute) || Implements(type, knownTypes.IUnion);
}

resolving System.Runtime.CompilerServices.UnionAttribute and System.Runtime.CompilerServices.IUnion through the existing WellKnownTypeProvider (both full names verified against a compiled union).

Placing the check after the explicit attribute checks keeps [DataContract] on a union working as an opt-in escape hatch.

Suggested message, distinct from the existing two reasons ("is not public" / "has no parameterless constructor"):

type 'Shape' is a union type whose state is not visible to DataContractSerializer: such members are silently serialized as empty and the data is lost

The existing generic-argument/array/Nullable<T> unwrapping already covers List<Shape>, Shape[] and Shape? for free.

Blocked on

Bumping Microsoft.CodeAnalysis.CSharp (see src/Directory.Packages.props, currently 4.13.0) to a version whose parser understands union. Until then a test can only hand-declare the compiler-emitted shape (a struct implementing a stub IUnion), which validates the symbol-level logic but not the real syntax.

Test cases to add once unblocked

  • warn on a plain union-typed [DataMember];
  • warn on List<Shape>, Shape[], Shape?;
  • detection via the interface alone, and via the attribute alone;
  • no warning when the union is explicitly marked with [DataContract];
  • no warning for a non-union struct with a get-only object property (guards against over-matching);
  • no warning for the union's case types themselves — they are ordinary types;
  • no warning when the member is not a [DataMember] or is marked [IgnoreDataMember].

Related

Also worth considering under the same umbrella: any type whose state is exposed exclusively through get-only members hits the same silent-data-loss path. Unions are just the case where the language guarantees that shape.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions