Skip to content

Fix runtime deserialization failure for polymorphic models with referenced extensible-enum discriminators#11180

Merged
jorgerangel-msft merged 2 commits into
mainfrom
rhurey/discriminated_enums
Jul 10, 2026
Merged

Fix runtime deserialization failure for polymorphic models with referenced extensible-enum discriminators#11180
jorgerangel-msft merged 2 commits into
mainfrom
rhurey/discriminated_enums

Conversation

@rhurey

@rhurey rhurey commented Jul 6, 2026

Copy link
Copy Markdown
Member

Polymorphic models whose discriminator is a referenced (external) extensible enum generated runtime-broken deserialization. The emitter produced:

@type = ModelReaderWriter.Read(prop.Value.GetUtf8Bytes(), ModelSerializationExtensions.WireOptions, Context.Default);

which throws  No ModelReaderWriterTypeBuilder found  at runtime, because the referenced type isn't registered with  ModelReaderWriter . It now correctly emits inline construction:

@type = new ResponseItemKind(prop.Value.GetString());

Root cause

External types resolve by reflection to a  System.Type . An emitter-generated extensible enum is a  struct , not a real .NET  enum , so reflection's  Type.IsEnum  is  false  →  CSharpType.IsEnum  is  false  → the serialization provider falls through to the  ModelReaderWriter.Read  path instead of the enum path. Real .NET enums survive because reflection detects them; extensible enums lose their enum semantics on the resolved  CSharpType .

Fix

Preserve enum semantics at the type-resolution layer rather than patching the serialization provider (fixes the cause, not the symptom):

•  CSharpType.cs  — made  _underlyingType  settable; added  WithUnderlyingEnumType(Type)  to produce a framework-backed copy that reports  IsEnum . Fixed  WithNullable  to preserve the underlying enum type for framework types (nullable/optional properties were stripping it).
•  TypeFactory.cs  — in  CreateCSharpTypeCore , when an external type resolves to a framework type and the input is  InputEnumType { IsExtensible: true } , reattach the underlying enum type via  WithUnderlyingEnumType .

Because emitter-generated extensible enums always expose a public constructor over their backing value and a public implicit operator (structural invariants of  ExtensibleEnumProvider ),  new T(value)  requires no cooperation from the referenced type, and non-string backing types (int/long/etc.) work automatically via deserialization recursion.

Tests

•  TypeFactoryTests : 3 tests covering external extensible string enum, external extensible int32 enum (both preserve enum semantics), and external fixed enum (does not force enum semantics).
•  MrwSerializationTypeDefinitionTests :  ReferencedExtensibleEnumUsesInlineConstruction  asserts deserialize emits  new ...(  and NOT  ModelReaderWriter.Read<...> , and serialize emits  WriteStringValue(...ToString()) .

Validation

• Core Generator: 1566/1566 passing
• ClientModel: 1450/1450 passing
•  npm run cop : passed
•  pwsh ./eng/scripts/Generate.ps1 : zero diffs to committed generated baselines

@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp label Jul 6, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-csharp@11180

commit: 112dd3a

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

No changes needing a change description found.

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

Fixes a runtime deserialization failure in the C# client generator when a polymorphic model’s discriminator is a referenced (external) extensible enum by ensuring the resolved external CSharpType preserves enum semantics (underlying type) so serialization emits inline construction rather than ModelReaderWriter.Read<T>.

Changes:

  • Updates TypeFactory.CreateCSharpTypeCore to reattach an underlying enum type to externally-resolved framework types when the input is an extensible enum.
  • Enhances CSharpType to preserve explicit underlying-enum semantics across WithNullable, and adds WithUnderlyingEnumType(Type) for framework types.
  • Adds unit tests to validate type-resolution semantics and verify MRW serialization emits inline construction (and not the MRW fallback) for referenced extensible enums.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs Adds coverage ensuring external extensible enums retain enum/struct semantics and underlying value type.
packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs Reattaches underlying enum type to resolved external framework types for extensible enums to drive correct serialization behavior.
packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs Adds WithUnderlyingEnumType and preserves underlying enum semantics when toggling nullability for framework types.
packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/MrwSerializationTypeDefinitions/MrwSerializationTypeDefinitionTests.cs Asserts deserialization uses inline construction (not ModelReaderWriter.Read<T>) and serialization writes underlying value for referenced extensible enums.

@JoshLove-msft JoshLove-msft 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.

Nice, focused root-cause fix — I like that it restores enum semantics at the type-resolution layer rather than special-casing serialization. I traced the affected paths and it holds together:

  • IsEnum => _underlyingType is not null, and the framework ctor yields null for a struct-backed referenced enum (e.g. Guid), so reattaching via WithUnderlyingEnumType is exactly what's needed. This is also consistent with how locally-generated extensible string enums already model _underlyingType = typeof(string) (e.g. TypeFactoryTests around lines 34/51/67/84, and the many UnderlyingEnumType.Equals(typeof(string)) call sites), so the change doesn't introduce a new modeling convention.
  • Serialize/deserialize shapes are runtime-safe for the string case: ToSerial (CSharpTypeSnippets.cs:70-74) emits value.ToString() for a struct + string underlying, and deserialize uses new T(value) — both structural invariants of the referenced struct, so no dependency on generated helpers in the other assembly. The added ReferencedExtensibleEnumUsesInlineConstruction test locks that in.
  • The WithNullable this-branch reassignment of _underlyingType is a value-preserving no-op and mirrors the existing post-construction mutation of _literal/_unionItemTypes, so relaxing _underlyingType from readonly is acceptable.

A few questions / suggestions (none blocking):

  1. Int/long-backed referenced extensible enum — write path. For a struct with a non-string underlying, ToSerial (CSharpTypeSnippets.cs:76-79) emits value.ToSerial{UnderlyingEnumType.Name}() as an extension method on the referenced type (e.g. ToSerialInt32()). For a locally-generated enum the emitter also generates that extension, but for a referenced enum in another package is that extension guaranteed to be public/accessible from the consuming assembly? The added int-backed test (...Int32...PreservesEnumSemantics) only asserts type resolution, not emitted serialization. A write-side test for an int-backed referenced extensible enum (or a note confirming that path is covered) would give me full confidence, since the PR's scope is runtime correctness.

  2. Discriminator/polymorphic coverage. The reported repro is a polymorphic model whose discriminator is a referenced extensible enum, but the new tests exercise a plain property + type resolution. Discriminator deserialization sometimes has its own code path — a test mirroring the actual repro (polymorphic base + external extensible-enum discriminator) would guard against regressions there specifically.

  3. Minor / FYI: CSharpType.Equals(Type) (CSharpType.cs:485-496) now returns false for externalExtensibleEnumType.Equals(typeof(Guid)) because IsEnum flips to true. I grepped the .Equals(typeof(...)) call sites and none compare an external extensible-enum type against its raw underlying framework Type, so I don't see an impact site — just flagging in case something in downstream (Azure) generators relies on that equality.

Overall LGTM pending the int-backed write-path question. Thanks for tracking this down.

--generated by Copilot

@JoshLove-msft

Copy link
Copy Markdown
Contributor

Following up on my earlier question about non-string-backed referenced extensible enums — I dug into it myself and reproduced it, so here's the definitive answer.

The string case is fully correct (serialize uses public ToString(), deserialize uses the public ctor), so discriminators — which are always string-backed — and string properties are 100% covered by this fix.

Int/long/float/double-backed referenced extensible enums have a latent gap on the write side. I generated serialization for a model with an int-backed external extensible-enum property (external: System.Guid, Int32Enum, isExtensible: true) and got:

// write
writer.WriteNumberValue(Kind.Value.ToSerialInt32());
// deserialize
kind = new global::System.Guid(prop.Value.GetInt32());

Deserialize is fine (public ctor). But ToSerial{Name}() is generated as internal (ExtensibleEnumSerializationProvider.cs:41-56), and the generated struct exposes no public accessor that returns the raw numeric underlying value — only a public ToString() (string) and an inbound public ctor / implicit operator (ExtensibleEnumProvider.cs). So for a referenced (cross-assembly) non-string extensible enum, the consuming assembly emits value.ToSerialInt32() against an internal member → CS0122 (unless InternalsVisibleTo is set between the two packages, which isn't the case for independently generated Azure packages).

Net effect for non-string referenced extensible enums: this PR trades the pre-existing runtime ModelReaderWriter.Read<T> throw for a compile-time error. Arguably better (fails at build), but still not compiling.

This is a niche combination (non-string extensible enums are rare, and referenced ones rarer still), so I don't think it should block this PR — the string path is the important, common fix. But a couple of options to close it out:

  1. Make the underlying-value accessor public 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 ctor/implicit-operator and would make cross-assembly serialization work. It is a public-API-surface addition, so it'd want API-review sign-off.
  2. Scope the enum-semantics restoration to string-backed extensible enums for now (skip WithUnderlyingEnumType when the underlying type isn't string) and file a follow-up for the non-string case, so we don't introduce a new compile break.

Either way, worth a short code comment noting non-string referenced extensible enums are not yet fully supported on the write path. Happy to help with whichever direction you prefer.

--generated by Copilot

@JoshLove-msft

Copy link
Copy Markdown
Contributor

Filed #11230 to track the non-string-backed referenced extensible enum write-path gap as a follow-up, so it doesn't block this PR. The string-backed path (the common case + all discriminators) is fully correct here.

--generated by Copilot

@jorgerangel-msft jorgerangel-msft added this pull request to the merge queue Jul 10, 2026
Merged via the queue into main with commit e0f8318 Jul 10, 2026
28 checks passed
@jorgerangel-msft jorgerangel-msft deleted the rhurey/discriminated_enums branch July 10, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants