Skip to content

Generated null checks fail to compile (CS9342) when the source type defines equality operators against its underlying primitive #2316

Description

@fdipuma

Please do the checklist before filing an issue:

  • I have read the documentation, including the FAQ
  • I can reproduce the bug using the latest prerelease version
  • I have searched existing discussion and issue to avoid duplicates

Describe the bug

When mapping a nullable reference-type member whose type defines user-defined equality operators against another type (the common "strongly-typed value object" pattern: ==/!=(Vo, Vo) and ==/!=(Vo, string)), Mapperly emits a bare null check (source.Member != null) in the generated mapping. That expression is ambiguous for the compiler, both operator overloads are applicable to the null literal, so the generated code does not compile.

This affects every value-object library that generates primitive comparison operators (e.g. Vogen, where primitiveEqualityGeneration is enabled by default), as well as hand-written value objects following the same pattern.

Repro

Self-contained declaration code below (no third-party dependency). I can push a repro repository on request.

Declaration code

public class Code
{
    public string Value { get; init; } = "";

    public static explicit operator string(Code v) => v.Value;

    public static bool operator ==(Code? l, Code? r) => Equals(l, r);
    public static bool operator !=(Code? l, Code? r) => !Equals(l, r);
    public static bool operator ==(Code? l, string? r) => l?.Value == r;
    public static bool operator !=(Code? l, string? r) => !(l == r);

    public override bool Equals(object? o) => o is Code c && c.Value == Value;
    public override int GetHashCode() => Value.GetHashCode();
}

public class Order { public Code? PromoCode { get; init; } }
public record OrderDto { public string? PromoCode { get; init; } }

[Mapper]
public static partial class OrderMapper
{
    public static partial OrderDto ToDto(this Order source);
}

Actual relevant generated code

var target = new OrderDto()
{
    PromoCode = source.PromoCode != null ? (string)source.PromoCode : default,
    //                           ^^^^^^^
    // error CS9342: Operator resolution is ambiguous between the following members:
    // 'Code.operator !=(Code?, Code?)' and 'Code.operator !=(Code?, string?)'
};

Expected relevant generated code

var target = new OrderDto()
{
    PromoCode = source.PromoCode != (Code?)null ? (string)source.PromoCode : default,
};

Casting the null literal to the source type disambiguates overload resolution without changing anything else, and seems the most general fix:

  • it is valid both in object (in-memory) mappings and inside queryable projection expression trees, so a single codegen shape fixes every context;
  • it is behavior-preserving: where the bare != null compiles today (types with a single applicable == overload), Mapperly already dispatches to the user-defined operator — the typed cast only disambiguates, it does not change which operator runs;
  • in projections the operator is never executed anyway, the ORM translates the comparison to IS NULL / IS NOT NULL.

An alternative for object mappings only would be pattern matching (source.PromoCode is not null), but it is not valid inside expression trees and it silently changes semantics from operator dispatch to reference equality, so the typed-null cast seems the safer and more consistent fix.

Reported relevant diagnostics

  • None from Mapperly, the failure is the C# compiler error CS9342 reported on the generated file (obj/.../Riok.Mapperly/...Mapper.g.cs).

Environment (please complete the following information):

  • Mapperly Version: 4.3.1 (also reproduced on 5.0.0-next.8)
  • Nullable reference types: enabled
  • .NET Version: SDK 10.0.300
  • Target Framework: net10.0
  • Compiler Version: 5.6.0-2.26230.102 (caa81fa4971f74880cdab61990cb1b11420939ec)
  • C# Language Version: latest (14.0)
  • IDE: Rider 2026.1
  • OS: Windows 11 Pro (10.0.26200)

Additional context

Workaround I use today: a user-implemented mapping with an explicitly typed null comparison, repeated for every nullable value-object type in every mapper:

private static string? MapPromoCode(Code? code) => code == (Code?)null ? null : (string)code;

I am happy to contribute a PR for this if you agree with the direction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions