Mapster icon indicating copy to clipboard operation
Mapster copied to clipboard

Adapt(source, destination) does not work when destination is a C# record

Open roohial57 opened this issue 3 weeks ago • 35 comments

When using Mapster to map data into an existing object, the mapping works correctly if the destination is a class, but does nothing when the destination is a C# record.

This appears to be caused by record immutability, but the current behavior is not clearly documented and can be misleading.

roohial57 avatar Dec 03 '25 16:12 roohial57

@roohial57 What version of Mapster are you using?

DocSvartz avatar Dec 03 '25 16:12 DocSvartz

in 7.4.0 Records in C# is not supported and record type from mapster not equal Records in C# ;

The latest pre-release versions have Records support. var result = source.Adapt(destinationRecord);

it using "with like modify" and you data should be in the result variable.

DocSvartz avatar Dec 03 '25 16:12 DocSvartz

@DocSvartz When using the overload to modify a record, the record is not changed because records are immutable. public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination)

Do you think we should just ignore this, or at least throw an error explaining that immutable types cannot be modified? Alternatively, should it be handled by passing the destination by ref?

roohial57 avatar Dec 06 '25 06:12 roohial57

@roohial57 Do you expect Records to behave immutably? In Mapster record types were originally intended to be immutable. When I fixed the problem #537, I save this behavior.

Do you think we should just ignore this, or at least throw an error explaining that immutable types cannot be modified?

In my opinion, we must write this information about different behavior for Records in Wiki. @andrerav @DevTKSS @stagep What do you think about it?

Alternatively, should it be handled by passing the destination by ref?

What do you mean? This behavior has nothing to do with how the destination is transmitted.

DocSvartz avatar Dec 06 '25 10:12 DocSvartz

Ok, that's right. it's the best solution!

roohial57 avatar Dec 06 '25 12:12 roohial57

In my opinion, the signature public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination) implies that the data from source is be mapped onto destination. When destination is immutable and this method is called, no error is thrown to indicate that the mapping did not occur. As a result, the user may assume the data was mapped successfully, while in fact no mapping actually took place. And this misleads the user.

@DocSvartz , Of course, your perspective is more comprehensive and inclusive than mine.

roohial57 avatar Dec 06 '25 13:12 roohial57

@DocSvartz This should be added to the documentation. I will put in some time over the weekend to review the issues where I need to respond. I have to understand what functionality docfx provides but I am sure it has something to handle documentation that is in review and not ready to be published. I am just not sure how granular this control is.

@roohial57 if I asked you how many ones can you put into zero, would you answer zero, or would you answer that this is a mathematical error. I would answer zero because that is the truth. So how many properties can you change on an immutable object? Zero. A mapper can only do what the destination allows it to do. So it was successful at mapping, it just mapped zero properties. If you map between 2 class instances and the classes have many properties but only one property that is mapped because it is of the same type and name, is it up to Mapster to tell you how many properties were mapped? I don't want to open a can of worms but I do see value from having an overload such as public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination, out int result) but this could be a long term goal if others see value in this. And if value was seen in this, then it would make an excellent first contribution, something I would consider doing myself. TBH, it sounds fairly rote and something that AI could probably do a large portion of. As long as it broke nothing and had excellent test coverage then it sounds fairly straightforward because you are simply ensuring the chain of results are passed up the chain to the final public method.

Also, the result would not have to be just an int, it could be a MappingResult type that has more than one property,

stagep avatar Dec 06 '25 13:12 stagep

@stagep Your idea is also Correct. If the destination is immutable, what does the following line do, and what problem would arise if it were removed?

source.Adapt(destination);

When a line of code does nothing and its presence or absence makes no difference, its existence creates ambiguity.

@stagep By the way, I really like the idea of MappingResult.

roohial57 avatar Dec 06 '25 14:12 roohial57

@roohial57 I agree with you 100% that there is ambiguity. It is like asking what is zero divided by zero. In mathematics, we say this is indeterminate. In the non mathematical world we would say it is ambiguous. I remember as a child having a physical calculator that would give you the result of 1 but it would flash 1. So it was ambiguous, because it was giving you an answer and an error at the same time. Why 1, because any number divided by itself is one, but any number divided by zero is any number. If that is not ambiguity then I don't know what is ambiguity :) Really enjoying this discussion with you, very constructive. Thanks.

stagep avatar Dec 06 '25 14:12 stagep

@stagep @roohial57 You are not taking into account the method signature, it does not return void :)

DocSvartz avatar Dec 06 '25 14:12 DocSvartz

source.Adapt(destination); -> var result = source.Adapt(destination);

source.Adapt(destination); This returns the mapping result.

And it seems to me that the result may be completely different depending on which Destination Type is used. 🤔

DocSvartz avatar Dec 06 '25 15:12 DocSvartz

@DocSvartz @roohial57 I realize now that we should prioritize returning the expected result when mapping to an existing record, over introducing a Result concept. I see 3 options.

  1. Introduce an Exception as an interim fix
  2. Do nothing and correct the behavior of records
  3. Do both 1 and 2

Here is some simplified code that demonstrates the change in handling records

var source = new Source(Id: 1, Name: "Source");
var destination = new Destination(Id: 2, Name: "Destination");

Console.WriteLine($"Source: {source}");
var mappedR = Map(source, destination);
Console.WriteLine($"Map: {mappedR}");
mappedR = MapNew(source, destination);
Console.WriteLine($"MapNew: {mappedR}");

Console.ReadKey();

static Destination Map(Source source, Destination destination)
{
    return destination;
}

static Destination MapNew(Source source, Destination destination)
{
    return destination with
    {
        Id = source.Id,
        Name = source.Name
    };
}

public record Source(int Id, string Name);
public record Destination(int Id, string Name);

The output is

Source: Source { Id = 1, Name = Source }
Map: Destination { Id = 2, Name = Destination }
MapNew: Destination { Id = 1, Name = Source }

I believe that MapNew returns the expected result. Am I correct?

stagep avatar Dec 06 '25 16:12 stagep

Adapt is currently working with Records .

like this:

static Destination MapNew(Source source, Destination destination)
{
    return destination with
    {
        Id = source.Id,
        Name = source.Name
    };
}

DocSvartz avatar Dec 06 '25 16:12 DocSvartz

This is how it works now

when

public class Destination()
{
  public int Id {get; set;} 
  string Name { get; set; };
}; 

static Destination Map(Source source, Destination destination)
{
     destination.id = source.id
     destination.Name = source.Name

    return destination;
}

DocSvartz avatar Dec 06 '25 16:12 DocSvartz

@stagep This is if we leave the concept of immutable types

// to mutable type
  public static void Adapt<TSource, TDestination>(this TSource source, TDestination destination)
 {
     if (typeof(TDestination).IsImmutable())
         throw new Exception($"{typeof(TDestination).Name} is Immutable Using  MapToTarget");

     Adapt(source, destination, TypeAdapterConfig.GlobalSettings);
 }

// to immutable type
 public static TDestination MapToTarget<TSource, TDestination>(this TSource source, TDestination destination)
 {
    return Adapt(source, destination, TypeAdapterConfig.GlobalSettings);
 }

DocSvartz avatar Dec 06 '25 16:12 DocSvartz

@DocSvartz You are absolutely right. I had not fully understood the correct way to use this method. I apologize for taking up your time.

roohial57 avatar Dec 07 '25 06:12 roohial57

@roohial57 @stagep I'm not a fan of ambiguity either :) I think we could supported two different methods, as I like indicated here. What do you think about this? Is this an optimal compromise?

DocSvartz avatar Dec 07 '25 08:12 DocSvartz

@DocSvartz Although the ambiguity has largely been resolved for me, I still welcome the suggestion of having two methods.

roohial57 avatar Dec 07 '25 09:12 roohial57

@DocSvartz sorry I didn't seen this notification. My network is broken today so I only can answer via mobile. I am not sure if I understood the now targeted solution correctly can you maybe explain it to me? I know Immutability from Mvux in Uno Platform development and they have some cool extension methods in place 🤔 Maybe there would be one to replicate for this? Like something like that: source.Create<T>() where it would be genius if we could anyhow integrate dto source gen into this, which is where I see the biggest lack of capability by now. We either can have dto creation or auto mappings but trouble using both at the same time.

DevTKSS avatar Dec 07 '25 12:12 DevTKSS

@DevTKSS

I am not sure if I understood the now targeted solution correctly can you maybe explain it to me?

Should the current public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination) be split into two separate methods capable of updating only mutable types and only immutable types?

I know Immutability from Mvux in Uno Platform development I looked, mapster have the same approach about the immutability of Records.

Maybe there would be one to replicate for this? Like something like that: source.Create<T>() where it would be genius if we could anyhow integrate dto source gen into this, which is where I see the biggest lack of capability by now. We either can have dto creation or auto mappings but trouble using both at the same time.

Could you provide an example? As far as I understand, it uses its own source code generator to create the necessary magic :)

DocSvartz avatar Dec 07 '25 13:12 DocSvartz

@DocSvartz @DevTKSS I believe that we simply have to decide that when we map (using map as the generic term for what Mapster does) and we map to a record, should we take the approach that records are immutable so you get back the same record, an exception is thrown, or we return a new record with the new values. Here is an AI summary when asked about this situation:

Mapping Library Behavior - when mapping from a class instance (mutable) to a record (immutable), the library has two choices:

Option 1

  • Provide a new record with updated values
  • This respects immutability
  • It aligns with developer expectations: mapping means “produce a mapped representation,” not “mutate the target.”
  • It avoids runtime errors and makes the library more usable

Option 2

  • Throw an exception because the record is immutable
  • This is technically correct (records can’t be mutated), but it’s hostile to developer ergonomics
  • It forces the caller to handle the mapping manually, defeating the purpose of the library
  • Exceptions should be reserved for unexpected or invalid states, not for normal usage

When I read the discussion about two separate methods, I see a code smell. In what scenario would someone mapping to an existing record want it to return the destination record and/or throw an exception? Returning a new record with the new values seems like the logical choice. If I am missing a use case where this behavior it is not logical, then please give me an example,

stagep avatar Dec 07 '25 14:12 stagep

@stagep I follow the Option 1 approach.

and i propose a redesign current method:

If a method is intended only for mutable entities, it should return nothing (void), and only mutate the destination. And then it can't work with immutable entities and must throw an exception. You can't change immutable.


  public static void Adapt<TSource, TDestination>(this TSource source, TDestination destination)
 {
     if (typeof(TDestination).IsImmutable())
         throw new Exception($"{typeof(TDestination).Name} is Immutable Using  MapToTarget");

     Adapt(source, destination, TypeAdapterConfig.GlobalSettings);
 }

And adding a new method only for immutable types in the concept of option 1

Since the concept of the current universal method public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination) raises questions about what will be the result of such a method depending on the mutability or immutability of the destination parameter type.

DocSvartz avatar Dec 07 '25 14:12 DocSvartz

@stagep Or maybe the Wiki description just needs to be changed. If this is just a misconception due to the documentation being out of date :) How I originally classified this issue.

DocSvartz avatar Dec 07 '25 14:12 DocSvartz

I 100% agree and missed the void versus return type method differentiation. The void method cannot mutate, the return method returns a new record with "updated" values. We will make this the first update to the documentation. But an update, as in, first lets get what we have into docfx, commit, then do a docs issue update / review / commit. I see this has the label "need Docs modify" already. Good work.

stagep avatar Dec 07 '25 15:12 stagep

@stagep If I understand correctly, you're in favor of the proposed separation concept. If yes, please mark this comment 👍

DocSvartz avatar Dec 07 '25 15:12 DocSvartz

@DocSvartz @DevTKSS I believe that we simply have to decide that when we map (using map as the generic term for what Mapster does) and we map to a record, should we take the approach that records are immutable so you get back the same record, an exception is thrown, or we return a new record with the new values. Here is an AI summary when asked about this situation:

Mapping Library Behavior - when mapping from a class instance (mutable) to a record (immutable), the library has two choices:

Option 1

  • Provide a new record with updated values
  • This respects immutability
  • It aligns with developer expectations: mapping means “produce a mapped representation,” not “mutate the target.”
  • It avoids runtime errors and makes the library more usable

Option 2

  • Throw an exception because the record is immutable
  • This is technically correct (records can’t be mutated), but it’s hostile to developer ergonomics
  • It forces the caller to handle the mapping manually, defeating the purpose of the library
  • Exceptions should be reserved for unexpected or invalid states, not for normal usage

When I read the discussion about two separate methods, I see a code smell. In what scenario would someone mapping to an existing record want it to return the destination record and/or throw an exception? Returning a new record with the new values seems like the logical choice. If I am missing a use case where this behavior it is not logical, then please give me an example,

I would see record mapping coming from the MVUX approach which is essentially statefull management with immutability as similar mapped to this:

source.AdaptAsRecord<SourceTypeDto>() which should defintly not throw! it should instead create a immutable record of the source type like: new SourceTypeDto with { Property1 = source.Property1 } and so on. Or fully immutable by using the ctor parameters!

DevTKSS avatar Dec 07 '25 22:12 DevTKSS

@DevTKSS the method that has no return type / returns void (however you look at it) has to throw an exception. The method that returns the type (the destination type) returns the new record with the new values as in your example. Understand?

stagep avatar Dec 07 '25 23:12 stagep

@stagep but why doesn't it has a return type? 🤔 Am I missing the overload or method that would be returning that what am thinking of?

DevTKSS avatar Dec 08 '25 03:12 DevTKSS

@DevTKSS

but why doesn't it has a return type? 🤔 To clearly indicate the expected result.

Describe what is happening in these code examples. And what do we expect to get when we call each of these methods.

// public static void WriteLine (string? value);;

var string = "Hello";
Console.WriteLine(string);


// public Uri MakeRelativeUri(Uri uri);


Uri address1 = new Uri("http://www.contoso.com/");
Uri address2 = new Uri("http://www.contoso.com/index.htm?date=today")

   address1.MakeRelativeUri(address2);  //  Where  will the result be that I expect to get from executing this method? 
(address1 or  address2 or in new instanse Url which will be returned method )

DocSvartz avatar Dec 08 '25 05:12 DocSvartz

@DocSvartz @stagep I think that using suitable parameter names can remove ambiguity. In the following method, based on the name of the second parameter (destination), it is expected that this parameter represents the result of the method: public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination destination)

However, this signature suggests that the second parameter (baseValue) is merely base data used by the method and is not the method result: public static TDestination Adapt<TSource, TDestination>(this TSource source, TDestination baseValue)

roohial57 avatar Dec 08 '25 06:12 roohial57