Skip to content

Add EPC42: data member is not serializable by DataContractSerializer - #333

Merged
SergeyTeplyakov merged 2 commits into
masterfrom
add-epc42-datacontract-serializable-member
Aug 13, 2026
Merged

Add EPC42: data member is not serializable by DataContractSerializer#333
SergeyTeplyakov merged 2 commits into
masterfrom
add-epc42-datacontract-serializable-member

Conversation

@SergeyTeplyakov

Copy link
Copy Markdown
Owner

Motivation

DataContractSerializer validates the object graph lazily. Constructing the serializer succeeds; the failure only surfaces on the first WriteObject call as an InvalidDataContractException. That makes it a great candidate for static analysis — the bug typically escapes unit tests that only construct the serializer, and shows up in production instead.

The canonical example is System.Net.IPAddress: serializable on the .NET Framework, but not on .NET Core, because it is neither marked with [Serializable] nor has a parameterless constructor.

[DataContract]
public class Config
{
    [DataMember]
    public IPAddress Address { get; set; } // EPC42: fails at runtime on .NET Core
}

To make it worse, IPAddress.Loopback / Any / None return the private nested IPAddress+ReadOnlyIPAddress, so even a hypothetically "fixed" IPAddress would still fail for those values.

What the rule does

EPC42 warns when a member of a [DataContract] type is marked with [DataMember] and its type is not data-contract-serializable.

A type is considered serializable when it is:

  • marked with [DataContract], [CollectionDataContract] or [Serializable], or
  • implements ISerializable or IXmlSerializable, or
  • satisfies the POCO rules: public and has a parameterless constructor (a non-public one is fine).

Arrays, Nullable<T> and generic arguments are unwrapped, so List<IPAddress> and IPAddress[] are reported the same way a plain IPAddress member is.

Deliberately not reported:

  • Interface-typed, abstract and object-typed members — these fail with a different exception (SerializationException, "…is not expected. Add any types…") and are solved with [KnownType].
  • Members without [DataMember] — the serializer ignores them.
  • Union types — not covered yet, tracked in EPC42: cover union types (requires a Roslyn update) #331.

Severity is Warning, enabled by default, no code fix (the fix is a design decision: change the type, add an attribute, or use a surrogate).

Verification

Every rule above was verified empirically against the real DataContractSerializer on net8.0 with round-trip WriteObject/ReadObject throwaway apps, rather than derived from the docs. A few results were surprising and shaped the implementation:

  • A non-public POCO fails, even though it is otherwise well-formed.
  • A private parameterless constructor is sufficient — hence the accessibility-agnostic constructor check.
  • A collection type without a default constructor fails with a distinct "invalid collection type" message.
  • Positional record class fails (no parameterless constructor), while record struct and body-only records are fine.

One notable implementation gotcha: [Serializable] is a metadata flag, not a real custom attribute, so GetAttributes() misses it for types coming from referenced assemblies. INamedTypeSymbol.IsSerializable is used instead.

Records

Records get explicit coverage: record class is TypeKind.Class and record struct is TypeKind.Struct, so they flow through the same path. Positional record properties are not IsImplicitlyDeclared (their DeclaringSyntaxReferences point at the ParameterSyntax), so [property: DataMember] is honoured and the diagnostic lands on the parameter — asserted by a location-checking test.

Shared helpers

Two helpers were added to SymbolExtensions and used by the new analyzer:

public static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol? attributeType);
public static bool ImplementsAny(this ITypeSymbol type, params INamedTypeSymbol?[] interfaceTypes);

Both are null-tolerant, since a well-known type may be absent from the compilation. #332 tracks migrating the pre-existing open-coded lookups onto them, and notes that NamedSymbolExtensions.IsDerivedFromInterface / IsType are dead code that compares assembly-qualified name strings and should be deleted.

Changes

  • CoreAnalyzers/DataContractSerializableMemberAnalyzer.cs — the analyzer.
  • CoreAnalyzers/DataContractSerializableMemberAnalyzerTests.cs — 23 tests.
  • DiagnosticDescriptors.cs, AnalyzerReleases.Unshipped.md, ReadMe.md — registration.
  • docs/Rules/EPC42.md — user-facing docs with four fix strategies.
  • SymbolExtensions.cs — the two shared helpers.

The analyzer bails out entirely at compilation start when System.Runtime.Serialization is not referenced, so there is no cost for projects that do not use it.

Full suite: 418/418 passing.

DataContractSerializer validates the object graph lazily: constructing the
serializer succeeds and the failure only shows up on the first WriteObject
call, as an InvalidDataContractException. This analyzer moves that failure
to compile time.

The canonical example is System.Net.IPAddress, which is serializable on the
.NET Framework but not on .NET Core, because it is neither marked with
[Serializable] nor has a parameterless constructor.

EPC42 warns when a member of a [DataContract] type is marked with
[DataMember] and its type is not data-contract-serializable, i.e. it is not
marked with [DataContract]/[CollectionDataContract]/[Serializable], does not
implement ISerializable or IXmlSerializable, and does not satisfy the POCO
rules (public and having a parameterless constructor, which may be
non-public). Arrays, Nullable<T> and generic arguments are unwrapped, so
List<IPAddress> and IPAddress[] are reported as well. All the rules were
verified empirically against the real serializer on net8.0.

Interface-typed, abstract and object-typed members are deliberately not
reported: they fail with a different exception and are solved with
[KnownType]. Union types are not covered yet, see #331.

Also adds two shared helpers to SymbolExtensions, HasAttribute and
ImplementsAny, replacing the open-coded lookups in the new analyzer. #332
tracks migrating the pre-existing call sites onto them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

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.

Pull request overview

This PR adds a new Roslyn analyzer rule (EPC42) to detect [DataMember] members on [DataContract] types whose member types are not serializable by DataContractSerializer, preventing a common “fails only on first WriteObject” runtime failure from escaping into production.

Changes:

  • Introduces DataContractSerializableMemberAnalyzer (EPC42) to flag non-serializable [DataMember] member types, including unwrapping arrays/Nullable<T>/generic arguments.
  • Adds a comprehensive test suite for EPC42 (including records and positional record scenarios).
  • Registers and documents the rule across descriptors, release notes, README, and new rule documentation; adds two reusable symbol helpers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/ErrorProne.NET.CoreAnalyzers/SymbolExtensions.cs Adds HasAttribute and ImplementsAny helpers used by the new analyzer.
src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs Registers EPC42 descriptor (title/message/description/help link).
src/ErrorProne.NET.CoreAnalyzers/CoreAnalyzers/DataContractSerializableMemberAnalyzer.cs Implements EPC42 analyzer logic and serialization checks.
src/ErrorProne.NET.CoreAnalyzers/AnalyzerReleases.Unshipped.md Adds EPC42 to the unshipped analyzer release list.
src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/DataContractSerializableMemberAnalyzerTests.cs Adds test coverage validating EPC42 behavior across many scenarios.
ReadMe.md Adds EPC42 to the public rule list.
docs/Rules/EPC42.md Adds user-facing documentation for EPC42, including rationale and fix strategies.
Suppressed comments (1)

src/ErrorProne.NET.CoreAnalyzers/CoreAnalyzers/DataContractSerializableMemberAnalyzer.cs:58

  • Spelling/grammar: in this comment, "can not" should be "cannot".
            // The same is true for the union types: a union is compiled into a struct.
            // Everything else (interfaces, enums, delegates) can not be a data contract.
            if (type.TypeKind != TypeKind.Class && type.TypeKind != TypeKind.Struct)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs
Replaces "can not" in the analyzer doc comment and inline comment, and the
"can't" contraction in the EPC42 message format. These were the only two
"can not" occurrences in the repo and the only contraction across all the
descriptor files, so the rule now matches the surrounding style.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@SergeyTeplyakov
SergeyTeplyakov merged commit b57da22 into master Aug 13, 2026
1 check passed
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