Skip to content

feat: honor null-accepting user-defined conversion operators#2371

Open
fdipuma wants to merge 4 commits into
riok:mainfrom
fdipuma:feat/2317-honor-operator-null-annotations
Open

feat: honor null-accepting user-defined conversion operators#2371
fdipuma wants to merge 4 commits into
riok:mainfrom
fdipuma:feat/2317-honor-operator-null-annotations

Conversation

@fdipuma

@fdipuma fdipuma commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Description

Implements parts 1–2 of #2317: Mapperly now honors a user-defined conversion operator's nullable reference type annotations, consistent with how NRT annotations are trusted elsewhere.

When a nullable source is converted through a user-defined operator whose parameter is nullable (e.g. explicit operator string?(Code? c)), the operator can accept null directly, so no null guard is generated:

// class Code { public static explicit operator string?(Code? c) => c?.Value; }
// A { Code? PromoCode } -> B { string? PromoCode }
target.PromoCode = (string?)source.PromoCode;          // before: source.PromoCode != null ? (string)source.PromoCode : default

An operator whose parameter is non-nullable keeps the guard, so it is never invoked with null:

// public static explicit operator string(Code c) => c.Value;
target.PromoCode = source.PromoCode != null ? (string)source.PromoCode : default;   // unchanged

This applies to object member/property mappings, nested members, and top-level mapper-method signatures.

How it works

A detector (MappingBuilderContext.HasNullAcceptingUserDefinedConversion) checks the operator's parameter annotation via Compilation.ClassifyConversion. At the two points where source nullability is normally stripped (FindOrBuildLooseNullableMapping for members, NullableMappingBuilder for top-level), the source nullability is preserved for such conversions so the resulting CastMapping keeps a nullable source type, and the existing null-handling logic then skips the guard. No guard-decision code was changed. Builder priority is preserved: the guard is only dropped when a cast is the conversion Mapperly would have selected anyway (a higher-priority Parse/constructor/etc. still wins for a nullable source).

Notes

Checklist

  • I did not use AI tools to generate this PR, or I have manually verified that the code is correct, optimal, and follows the project guidelines and architecture
  • I understand that low-quality, AI-generated PRs will be closed immediately without further explanation
  • The existing code style is followed
  • The commit message follows our guidelines
  • Performed a self-review of my code
  • Hard-to-understand areas of my code are commented
  • The documentation is updated (as applicable)
  • Unit tests are added/updated
  • Integration tests are added/updated (as applicable, especially if feature/bug depends on roslyn or framework version in use)

fdipuma added 4 commits July 16, 2026 15:09
…ors; add coverage

Rebuild the null-accepting-operator cast only after the normal pipeline has
already selected a CastMapping for the nullable source, so higher-priority
conversions (Parse, ctor, enumerable, ...) keep precedence over the
null-accepting operator shortcut. Adds regression coverage for the priority
collision plus nested-member, implicit-operator, and value-type-guard cases,
and documents the RMG089 suppression for nullable member -> non-nullable
target member via a null-accepting operator.
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.

1 participant