You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(http-client-csharp): preserve explicit null for optional nullable properties - #11971
Generated model classes now distinguish an untouched optional nullable property from one explicitly assigned null. Omitted properties remain omitted, explicit null writes JSON null, and non-null values keep their existing representation. JSON deserialization preserves these states for subsequent JSON and wire-format serialization.
The generator uses private backing storage and nonpublic presence flags for scalar/model properties, and reuses change-tracking collection state for nullable lists and dictionaries. It preserves inherited properties, including dynamic models with a non-dynamic base, without changing public property types or constructor/factory signatures. Expected projects were regenerated with the supported scripts; generated files were not hand-edited.
Compatibility boundaries
Required-nullable, optional-nonnullable, read-only wire filtering, XML serialization, and JsonPatch override precedence retain their existing behavior.
Defaulted constructor/factory scalar arguments retain null-as-omitted semantics. Existing factory collection initialization also remains unchanged, including default lists materialized as empty lists. Assign a writable property after construction to request explicit null.
Handwritten replacement properties/fields and models customized as readonly structs retain their existing behavior; no public presence-tracking API or replacement-member opt-in is introduced. These boundaries are documented in the customization guide.
Coverage and validation
Regression coverage includes omitted/null/value states, assignment transitions, model references, strings, integers, booleans, enums, dates, bytes, nullable collection elements, nested wire serialization, both JSON interfaces, round trips, duplicate-property last-value-wins, inheritance, name collisions, handwritten customization, factory defaults, read-only properties, and dynamic patches.
4,538 generator/local unit tests passed.
31 real Spector nullable and JSON merge-patch tests passed; test libraries were regenerated back to their standard stub form afterward.
244 emitter tests passed, with 2 existing skips (--maxWorkers=4 avoids contention against the existing five-second timeout).
Full Generate.ps1, focused final regeneration, npm run build, API extraction, repository/emitter formatting, and C# formatting passed.
Cop checks passed. Repository oxlint scanned 70 emitter files with zero diagnostics; the package's existing npm run lint still references removed ESLint tooling, so the repository's current linter was used instead.
Track generated nullable property presence independently of its value and preserve absent/null collection states during JSON deserialization. Cover assignment, round trips, inheritance, customization, factory defaults, and dynamic patch precedence.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This treats every redeclared inherited property as sharing the base presence flag, even when ModelProvider marks the derived member new for a domain change (ModelProvider.cs:704-719). The generated new property keeps its field-based setter because this update only rewrites Override setters, so assigning null or a value never sets the shared flag and serialization omits the property; share the flag only for overrides and let new properties allocate their own state.
if (property.BaseProperty != null)
{
var baseProperty = BaseModelProvider?.Properties.FirstOrDefault(p => p.Name == property.BaseProperty.Name);
if (baseProperty != null && GetNullablePropertyPresence(baseProperty) is { } basePresence)
{
_nullablePropertyPresence[property] = basePresence;
if (property.Modifiers.HasFlag(MethodSignatureModifiers.Override))
{
property.Update(body: new MethodPropertyBody(
Return(Base.Property(baseProperty.Name)),
property.Body.HasSetter ? Base.Property(baseProperty.Name).Assign(Value).Terminate() : null));
}
}
return;
This reservation only includes generated m.Fields, but inherited handwritten fields are kept in m.CustomCodeView.Fields and merged separately by CanonicalTypeProvider (Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs:246-247,305-306). A base customization with an accessible field such as _textIsDefined can therefore receive the same name as a generated presence field in a derived model, producing a field-hiding warning; this generator treats warnings as errors (generator/Directory.Build.props:29). Include inherited CustomCodeView.Fields in the reserved names and add an inheritance/custom-field regression test.
Reviewed the full cumulative diff against main (ScmModelProvider.cs, MrwSerializationTypeDefinition.cs/.Xml.cs, the docs update, and all test changes).
Found and fixed: an unresolved reviewer thread on AssertJsonShape (ModelSerializationExtensionsTests.cs) that had been missed in prior feedback passes — the switch wasn't exhaustive over JsonValueKind. I verified empirically that JsonValueKind.True/False/Null are distinct enum values, so the existing top-level ValueKind equality check already caught mismatches (the specific "won't throw" claim didn't reproduce), but added explicit cases for those kinds plus a default branch asserting GetRawText() equality for defense-in-depth. Pushed in 17e8fdf, replied to and resolved the thread.
No other actionable findings:
No changeset needed — @typespec/http-client-csharp isn't in any .chronus version policy (confirmed in .chronus/config.yaml), consistent with this package's own no-changelog convention.
The XML null-collection guard fix (MrwSerializationTypeDefinition.Xml.cs, using IsConcreteCollection + explicit null check before the runtime IsCollectionDefined helper) is a correct, necessary consequence of collections now being able to hold an explicit null at runtime.
Test coverage is thorough and exercises the real generator entry points (CreateModel, generated-output snapshot comparisons) across inheritance, custom/handwritten members, readonly structs, additional-properties unions, and XML — including several edge cases (0/1/128 properties, dynamic models, collision naming).
Full ClientModel suite (2219), Local suite (546 incl. the new case), and npm run cop all pass.
This initializes every optional-nullable collection to its PropertyInitializationType, but concrete collections cannot represent an undefined state: OptionalSnippets.IsCollectionDefined explicitly treats null as undefined for them. For a concrete List<T>/dictionary property, an absent JSON property now becomes an empty collection and the writer emits []/{} instead of omitting it. Restrict this initialization to non-concrete, non-ReadOnlyMemory collections that use change-tracking storage (or otherwise leave concrete collections as default).
Addressed the previously missed concrete optional-collection finding in ecb13c5.
Optional-nullable collections are now eagerly initialized only when their generated type uses change-tracking/interface storage. Customized concrete collections (for example, List<T>) remain default when the JSON property is absent, so subsequent serialization omits them instead of emitting an empty collection.
Added a regression covering concrete list and dictionary customizations alongside IList<T>/IDictionary<TKey, TValue> change-tracking controls. The test failed before the fix (List<T> initialized to ChangeTrackingList<T>) and passes afterward.
Validation: package build/API extraction, full ClientModel suite (2,220), full Local suite (546), emitter tests (381 passed, 2 skipped), full regeneration, Cop, and formatting all pass.
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
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.
Summary
Fixes #11970.
Generated model classes now distinguish an untouched optional nullable property from one explicitly assigned
null. Omitted properties remain omitted, explicit null writes JSON null, and non-null values keep their existing representation. JSON deserialization preserves these states for subsequent JSON and wire-format serialization.The generator uses private backing storage and nonpublic presence flags for scalar/model properties, and reuses change-tracking collection state for nullable lists and dictionaries. It preserves inherited properties, including dynamic models with a non-dynamic base, without changing public property types or constructor/factory signatures. Expected projects were regenerated with the supported scripts; generated files were not hand-edited.
Compatibility boundaries
Coverage and validation
Regression coverage includes omitted/null/value states, assignment transitions, model references, strings, integers, booleans, enums, dates, bytes, nullable collection elements, nested wire serialization, both JSON interfaces, round trips, duplicate-property last-value-wins, inheritance, name collisions, handwritten customization, factory defaults, read-only properties, and dynamic patches.
--maxWorkers=4avoids contention against the existing five-second timeout).Generate.ps1, focused final regeneration,npm run build, API extraction, repository/emitter formatting, and C# formatting passed.npm run lintstill references removed ESLint tooling, so the repository's current linter was used instead.No C# changelog/Chronus entry is included.