Skip to content

Non-required init properties with initializers should be omittable from object initializer when source is null #2178

@rcdailey

Description

@rcdailey

Is your feature request related to a problem? Please describe.

When mapping from a nullable source property to a non-nullable init target property that has a default initializer, Mapperly always includes the property in the object initializer and generates ?? throw new ArgumentNullException(...) (or ?? default/?? new() with ThrowOnMappingNullMismatch = false).

For non-required init properties, C# allows omitting them from the object initializer entirely, which would let the target type's own initializer provide the default value. Mapperly doesn't take advantage of this; it treats all init properties as if they were required.

This is a problem when mapping from DTOs with all-nullable properties (common for deserialized config/YAML/JSON) to domain models that use non-nullable init properties with sensible defaults.

Describe the solution you'd like

When ThrowOnPropertyMappingNullMismatch is false (the default) and the target property is a non-required init property with a default initializer, Mapperly should omit the property from the object initializer when the source is null, preserving the target's default.

// Source
public record SourceDto
{
    public string? Name { get; init; }
    public IReadOnlyCollection<string>? Items { get; init; }
    public bool? Enabled { get; init; }
}

// Target
public record Target
{
    public string Name { get; init; } = "";
    public IReadOnlyCollection<string> Items { get; init; } = [];
    public bool Enabled { get; init; }
}

[Mapper]
public static partial class MyMapper
{
    public static partial Target Map(SourceDto source);
}

// Current generated code (v4.3.1):
public static partial Target Map(SourceDto source)
{
    var target = new Target()
    {
        Name = source.Name ?? throw new ArgumentNullException(nameof(source.Name)),
        Items = source.Items ?? throw new ArgumentNullException(nameof(source.Items)),
        Enabled = source.Enabled ?? throw new ArgumentNullException(nameof(source.Enabled)),
    };
    return target;
}

// Desired generated code:
public static partial Target Map(SourceDto source)
{
    var target = new Target()
    {
        Name = source.Name ?? "",              // or omit entirely
        Items = source.Items ?? [],            // or omit entirely
        Enabled = source.Enabled ?? default,   // or omit entirely
    };
    return target;
}

Ideally, non-required init properties would simply be omitted from the initializer when the source is null, equivalent to how set properties get a conditional if (source.X != null) post-construction assignment. The target's own field initializer handles the default.

Describe alternatives you've considered

  • ThrowOnMappingNullMismatch = false: This changes the fallback from throw to ?? default/?? new(), which partially helps. However, it fails for interface collection types (ICollection<T>, IReadOnlyCollection<T>) with RMG002 because they have no parameterless constructor. It also doesn't truly "skip" the assignment; it substitutes a value that may differ from the target's initializer.

  • Changing init to set: This works because Mapperly generates conditional post-construction assignments for set properties. But it sacrifices the init contract solely to work around Mapperly's code generation, which feels backwards.

  • [MapperIgnoreTarget]: This always skips the property regardless of source value, which isn't the desired behavior (we want to map when the source is non-null).

Additional context

This is related to #1825 (closed without resolution). The docs state that AllowNullPropertyAssignment, ThrowOnPropertyMappingNullMismatch, and ThrowOnMappingNullMismatch are "ignored for required init properties," which implies non-required init properties should respect these settings. In practice, ThrowOnPropertyMappingNullMismatch (the "skip the assignment" behavior) has no effect on any init property, required or not.

The root cause appears to be in NewInstanceObjectMemberMappingBodyBuilder.BuildInitMemberMappings(), which uses CodeStyle.Expression for all init properties. Expression context can't contain if statements, but it could omit the property entirely from the initializer when the source is null, since C# only requires required properties to appear.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breaking-changeThis issue or pull request will break existing consumersbugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions