Summary
Non-string-backed (int/long/float/double) referenced (external, cross-assembly) extensible enums serialize through an internal helper that is inaccessible from the consuming assembly, producing non-compiling code.
This was found while reviewing #11180, which restores enum semantics for referenced extensible enums so they serialize/deserialize inline instead of falling through the (runtime-throwing) ModelReaderWriter.Read<T> path. That fix is fully correct for string-backed extensible enums (the common case, and all discriminators). This issue tracks the remaining non-string case, which #11180 intentionally leaves for a follow-up.
Repro
A model with a property typed as an int-backed external extensible enum generates:
// write
writer.WriteNumberValue(Kind.Value.ToSerialInt32());
// deserialize
kind = new global::System.Guid(prop.Value.GetInt32()); // (System.Guid used as the external stand-in in the test)
Deserialize is fine — it uses the enum's public constructor. The write path is the problem.
Root cause
- The write path for an extensible (struct) enum with a non-string underlying type emits
value.ToSerial{UnderlyingEnumType.Name}() (MrwSerializationTypeDefinition.SerializeJsonValueCore, and CSharpTypeSnippets.ToSerial).
- That
ToSerial{Name}() helper is generated with Internal accessibility (ExtensibleEnumSerializationProvider.BuildMethods, Modifiers: MethodSignatureModifiers.Internal).
- The generated extensible-enum struct exposes no public accessor that returns the raw numeric underlying value — only a public
ToString() (string), a public constructor, and a public inbound implicit operator (underlying → enum) (ExtensibleEnumProvider.BuildMethods / BuildConstructors).
So for a referenced non-string extensible enum, the consuming assembly emits value.ToSerialInt32() against an internal member in the other assembly → CS0122 (there is no InternalsVisibleTo between independently generated packages).
For the string case there is no such gap: serialize uses the public ToString() and deserialize uses the public constructor, both accessible cross-assembly.
Impact
Niche: non-string extensible enums are rare, and referenced (cross-package) ones rarer still. Before #11180 this scenario failed at runtime (the ModelReaderWriter.Read<T> throw); after #11180 (if not scoped) it would fail at compile time. #11180 scopes the enum-semantics restoration to string-backed enums, so this scenario retains its prior (pre-#11180) behavior until this issue is addressed.
Proposed fix options
- Expose the underlying value publicly for non-string extensible enums — e.g. make
ToSerial{Name}() public, or add a public explicit operator to the underlying type. This mirrors the already-public inbound constructor/implicit operator and makes cross-assembly serialization work. It is a public-API-surface addition, so it needs API-review sign-off.
- Keep the current scoping and, once (1) lands, extend the enum-semantics restoration in
TypeFactory.CreateExternalType to non-string underlying types as well.
Acceptance
- A referenced int-backed extensible enum used as a model property (and as a polymorphic discriminator) round-trips through JSON serialization with code that compiles across assemblies.
- Unit tests covering the write path for a non-string referenced extensible enum.
Summary
Non-string-backed (int/long/float/double) referenced (external, cross-assembly) extensible enums serialize through an
internalhelper that is inaccessible from the consuming assembly, producing non-compiling code.This was found while reviewing #11180, which restores enum semantics for referenced extensible enums so they serialize/deserialize inline instead of falling through the (runtime-throwing)
ModelReaderWriter.Read<T>path. That fix is fully correct for string-backed extensible enums (the common case, and all discriminators). This issue tracks the remaining non-string case, which #11180 intentionally leaves for a follow-up.Repro
A model with a property typed as an int-backed external extensible enum generates:
Deserialize is fine — it uses the enum's public constructor. The write path is the problem.
Root cause
value.ToSerial{UnderlyingEnumType.Name}()(MrwSerializationTypeDefinition.SerializeJsonValueCore, andCSharpTypeSnippets.ToSerial).ToSerial{Name}()helper is generated withInternalaccessibility (ExtensibleEnumSerializationProvider.BuildMethods,Modifiers: MethodSignatureModifiers.Internal).ToString()(string), a public constructor, and a public inbound implicit operator (underlying → enum) (ExtensibleEnumProvider.BuildMethods/BuildConstructors).So for a referenced non-string extensible enum, the consuming assembly emits
value.ToSerialInt32()against aninternalmember in the other assembly → CS0122 (there is noInternalsVisibleTobetween independently generated packages).For the string case there is no such gap: serialize uses the public
ToString()and deserialize uses the public constructor, both accessible cross-assembly.Impact
Niche: non-string extensible enums are rare, and referenced (cross-package) ones rarer still. Before #11180 this scenario failed at runtime (the
ModelReaderWriter.Read<T>throw); after #11180 (if not scoped) it would fail at compile time. #11180 scopes the enum-semantics restoration to string-backed enums, so this scenario retains its prior (pre-#11180) behavior until this issue is addressed.Proposed fix options
ToSerial{Name}()public, or add a public explicit operator to the underlying type. This mirrors the already-public inbound constructor/implicit operator and makes cross-assembly serialization work. It is a public-API-surface addition, so it needs API-review sign-off.TypeFactory.CreateExternalTypeto non-string underlying types as well.Acceptance