Add EPC42: data member is not serializable by DataContractSerializer - #333
Merged
SergeyTeplyakov merged 2 commits intoAug 13, 2026
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
DataContractSerializervalidates the object graph lazily. Constructing the serializer succeeds; the failure only surfaces on the firstWriteObjectcall as anInvalidDataContractException. 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.To make it worse,
IPAddress.Loopback/Any/Nonereturn the private nestedIPAddress+ReadOnlyIPAddress, so even a hypothetically "fixed"IPAddresswould 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:
[DataContract],[CollectionDataContract]or[Serializable], orISerializableorIXmlSerializable, orArrays,
Nullable<T>and generic arguments are unwrapped, soList<IPAddress>andIPAddress[]are reported the same way a plainIPAddressmember is.Deliberately not reported:
abstractandobject-typed members — these fail with a different exception (SerializationException, "…is not expected. Add any types…") and are solved with[KnownType].[DataMember]— the serializer ignores them.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
DataContractSerializeron net8.0 with round-tripWriteObject/ReadObjectthrowaway apps, rather than derived from the docs. A few results were surprising and shaped the implementation:record classfails (no parameterless constructor), whilerecord structand body-only records are fine.One notable implementation gotcha:
[Serializable]is a metadata flag, not a real custom attribute, soGetAttributes()misses it for types coming from referenced assemblies.INamedTypeSymbol.IsSerializableis used instead.Records
Records get explicit coverage:
record classisTypeKind.Classandrecord structisTypeKind.Struct, so they flow through the same path. Positional record properties are notIsImplicitlyDeclared(theirDeclaringSyntaxReferencespoint at theParameterSyntax), 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
SymbolExtensionsand used by the new analyzer: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/IsTypeare 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.Serializationis not referenced, so there is no cost for projects that do not use it.Full suite: 418/418 passing.