Skip to main content

Compare, Clone and Map

These methods work on whole objects through reflection. CompareProperties tells you which properties changed, ApplyTo replays those changes elsewhere, DeepClone copies an object graph, and MapTo and UpdateFrom copy properties by name between different types. Reach for them for audit logs, undo snapshots, entity-to-DTO mapping and PATCH endpoints when a full mapping library is more than you need.

All methods live in the System namespace, so you do not need a using directive.

At a glance​

MethodWhat it does
ComparePropertiesLists the public properties that differ between two objects, with dotted paths for nested objects.
PropertyChangeOne changed property: PropertyName, OldValue, NewValue, and a log-friendly ToString().
ApplyToSets each change's new value on another object, following dotted paths.
DeepCloneCopies an object and everything it holds through System.Text.Json.
MapTo<TDestination>Creates a new object of another type and copies every property with the same name.
UpdateFromCopies matching properties onto an existing object, skipping null values by default (PATCH).
Reflection and trimming

The five methods use reflection (PropertyChange itself does not), and DeepClone uses reflection-based System.Text.Json. They carry [RequiresUnreferencedCode], and DeepClone, MapTo and UpdateFrom also carry [RequiresDynamicCode]. See Trimming and AOT before you use them in a trimmed or Native AOT app.

Compare and replay​

CompareProperties​

before.CompareProperties(after) returns one PropertyChange for each public property whose value differs. Use it to write an audit log entry, to show a user what an edit will change, or to build a patch you can replay.

What changed on a customer
Customer before = new Customer
{
Email = "a@x.com",
Age = 40,
Tags = new List<string> { "vip", "new" },
Address = new PostalAddress { City = "Amman", Street = "Rainbow" },
};

Customer after = before.DeepClone();
after.Email = "b@x.com";
after.Tags[1] = "gold";
after.Address!.City = "Irbid";

IReadOnlyList<PropertyChange> changes = before.CompareProperties(after);

int count = changes.Count; // → 3
string firstName = changes[0].PropertyName; // → "Email"
string nestedName = changes[2].PropertyName; // → "Address.City"
string log = string.Join("\n", changes);
Output
Email: "a@x.com" -> "b@x.com"
Tags: ["vip", "new"] -> ["vip", "gold"]
Address.City: "Amman" -> "Irbid"

How values are compared:

  • Properties only. Every public instance property with a getter is compared, in declaration order. Indexers and public fields are not compared.
  • Nested objects of the same runtime type are compared property by property. A change inside them is reported with a dotted path such as Address.City, not as a change to Address.
  • A nested object that is null on one side is reported as a change to the parent property. OldValue is null and NewValue is the whole object (and the other way round).
  • Nested objects of different runtime types (a Dog before, a Cat after) are reported as a change to the whole property.
  • Collections are compared item by item, in order. Items that are objects are compared by their properties, so an equal copy of a list is not a change. When any item differs, or the lengths differ, the whole collection is reported once, with the old and new collections as the values. Reordering the same items counts as a change.
  • Everything else (strings, numbers, enums, dates, DateOnly) is compared with object.Equals.
  • Runtime type. When both objects have the same runtime type, that type's properties are compared, so a Shape variable that holds two Circle objects also compares Radius. When the runtime types differ, the properties of T are compared.

An object compared with itself, or with its DeepClone, returns an empty list.

Cycles throw

Comparison follows nested objects up to 16 levels deep. An object graph that refers back to itself (node.Next = node) goes past that limit and throws InvalidOperationException with the message "The objects nest deeper than 16 levels; they probably contain a cycle." Compare a flat projection of such graphs instead.

  • null for before or after throws ArgumentNullException with ParamName "before" or "after".

PropertyChange​

PropertyChange is the item type that CompareProperties returns. It is a small immutable class with three properties and a ToString() meant for logs. You can also create one yourself, for example to replay a change read back from an audit table.

MemberTypeMeaning
PropertyChange(string propertyName, object? oldValue, object? newValue)constructorpropertyName is required; null throws ArgumentNullException (ParamName "propertyName").
PropertyNamestringThe property name, or a dotted path for a nested property ("Address.City").
OldValueobject?The value before the change.
NewValueobject?The value after the change.
ToString()stringName: old -> new, formatted as described below.
Formatting a change for a log
PropertyChange email = new PropertyChange("Email", "a@x.com", "b@x.com");
PropertyChange phone = new PropertyChange("Phone", null, "123");
PropertyChange price = new PropertyChange("Price", 1234.5m, 2.25);
PropertyChange tags = new PropertyChange("Tags", new[] { "a", "b" }, new object?[] { 1.5, null });

string emailText = email.ToString(); // → "Email: \"a@x.com\" -> \"b@x.com\""
string phoneText = phone.ToString(); // → "Phone: null -> \"123\""
string priceText = price.ToString(); // → "Price: 1234.5 -> 2.25"
string tagsText = tags.ToString(); // → "Tags: [\"a\", \"b\"] -> [1.5, null]"

How ToString() writes each value:

  • null is written as null.
  • Strings are wrapped in double quotes.
  • Numbers, dates and other IFormattable values use the invariant culture, so 1234.5m is 1234.5 even when the current culture is de-DE. A decimal keeps its scale: 120.00m is written 120.00.
  • Collections are written as [item, item], each item formatted by the same rules.
  • Any other value uses its own ToString(). For a class that does not override it, that is the type name, so a whole nested object reads as Address: null -> MyApp.PostalAddress.
note

The arrow in the output is the two ASCII characters ->.

ApplyTo​

changes.ApplyTo(target) sets each change's NewValue on target, in order. Together with CompareProperties it works as a patch: compare two versions of an object, then apply the difference to a third object.

Replay an edit onto another copy
Customer before = LoadCustomer(); // Email "a@x.com", Address.City "Amman", Tags ["vip", "new"]
Customer after = LoadCustomer();
after.Email = "b@x.com";
after.Address!.City = "Irbid";
after.Tags = new List<string> { "gold" };

Customer replica = LoadCustomer(); // a third copy, for example in a read model
before.CompareProperties(after).ApplyTo(replica);

string email = replica.Email!; // → "b@x.com"
string city = replica.Address!.City!; // → "Irbid"
List<string> tags = replica.Tags; // → ["gold"]
int remaining = replica.CompareProperties(after).Count; // → 0
  • Dotted paths are followed: Address.City sets replica.Address.City. Each segment must be a public instance property.
  • Values are converted to the property type with invariant culture, so new PropertyChange("Age", 40, "42") sets an int Age to 42. This helps when you replay changes stored as text.
  • Old values are not checked. ApplyTo does not compare OldValue with the target's current value. If you need optimistic concurrency, check it yourself first.
  • The method takes any IEnumerable<PropertyChange>, so you can filter with LINQ first: changes.Where(change => change.PropertyName != "Email").ApplyTo(replica).
Values are assigned, not copied

A collection change sets the collection instance itself. After the example above, replica.Tags and after.Tags are the same List<string>, so adding to one adds to the other. The same is true for a whole nested object that was null before. Apply changes from a DeepClone of the source when the two objects must stay independent.

Errors:

SituationException
changes or target is nullArgumentNullException (ParamName "changes" or "target")
changes contains a null entryArgumentException (ParamName "changes")
A path names a property that does not existArgumentException: "Cannot apply 'Address.Planet': type 'MyApp.PostalAddress' has no public property named 'Planet'."
An intermediate object is nullArgumentException: "Cannot apply 'Address.City': 'Address' is null."
The last property has no setterArgumentException: "Cannot apply 'Id': property 'Id' of type 'MyApp.Customer' is read-only."
A path is empty or has an empty segment ("", "Address..City", "Address.")ArgumentException: "The property path '...' is not valid."
A value cannot be converted to the property typean exception from the conversion (see the note)

The path errors have ParamName "path", and their messages show the type's full name (MyApp.PostalAddress above, for a class declared in the MyApp namespace). Changes are applied one by one, so the changes before the one that failed stay applied.

note

The XML documentation names InvalidCastException for a value that cannot be converted. A non-numeric string such as "abc" for an int property currently surfaces as FormatException. Catch both if you replay untrusted data.

Copy​

DeepClone​

source.DeepClone() serializes the object to JSON with System.Text.Json and reads it back. Nested objects and collections are copied too, so changing the copy never changes the original. Use it for snapshots before an edit, for undo, and for test fixtures.

A snapshot that shares nothing with the original
Order original = new Order
{
Number = "SO-1001",
Lines = new List<OrderLine> { new OrderLine { Sku = "A-1", Quantity = 2 } },
ShippingAddress = new PostalAddress { City = "Amman" },
};

Order copy = original.DeepClone();
copy.Lines.Add(new OrderLine { Sku = "B-7", Quantity = 1 });
copy.ShippingAddress!.City = "Irbid";

int originalLines = original.Lines.Count; // → 1
string originalCity = original.ShippingAddress!.City!; // → "Amman"
bool sameList = ReferenceEquals(copy.Lines, original.Lines); // → false
int differences = original.CompareProperties(original.DeepClone()).Count; // → 0

What is copied:

  • Public properties, including properties with a private setter (public string? Owner { get; private set; }).
  • Get-only collection and object properties (public List<string> Roles { get; } = new List<string>();), which are filled in place. This needs a parameterless constructor on the declaring type.
  • Public fields.
  • Records, including positional records (record Point(int X, int Y, List<string> Labels)), through their constructor.
  • The runtime type of the object you call it on: Shape shape = new Circle { Radius = 3 } clones to a Circle.

What is not copied: private fields, and get-only properties of simple types, strings and arrays (computed properties such as public decimal Total => ... are recomputed anyway). System.Text.Json attributes such as [JsonIgnore] apply as usual.

Nested properties follow System.Text.Json rules

Only the root object keeps its runtime type. A nested property is copied as its declared type: a property declared as Animal that holds a Dog comes back as a plain Animal (the Dog members are lost), and a property declared as object comes back as a JsonElement. Use MapTo to copy such graphs, or add System.Text.Json polymorphism attributes to the base type.

Errors:

  • null throws ArgumentNullException (ParamName "source").
  • A reference cycle (node.Next = node) throws JsonException.
  • A type that System.Text.Json cannot serialize or create throws NotSupportedException.

Map between types​

MapTo and UpdateFrom copy properties by name from one object to another, possibly of a different type. They share one set of rules. UpdateFrom adds PATCH behavior on top.

Over-posting

Every public property whose name matches is copied. When the source comes from a client (a request body), map from a DTO that holds only the fields the caller is allowed to set. Otherwise a client can send IsAdmin or Balance and have it copied.

MapTo<TDestination>​

source.MapTo<TDestination>() creates a new TDestination and copies every public property with the same name. Use it to turn an entity into a DTO, or a DTO into a new entity. TDestination needs a public parameterless constructor (where TDestination : new()).

Entity to DTO
Order order = new Order
{
Id = 7,
Status = OrderStatus.Paid,
Total = 1234.5m,
Customer = new OrderCustomer { Id = 3, Name = "Ahmad" },
Lines = new List<OrderLine>
{
new OrderLine { Product = "Pen", Quantity = 2 },
new OrderLine { Product = "Book", Quantity = 1 },
},
};

// OrderDto: string? Id, string? Status, string? Total, CustomerDto? Customer,
// OrderLineDto[]? Lines, string Note = "none"
OrderDto dto = order.MapTo<OrderDto>();

string? id = dto.Id; // → "7"
string? status = dto.Status; // → "Paid"
string? total = dto.Total; // → "1234.5"
string? customerName = dto.Customer!.Name; // → "Ahmad"
int lineCount = dto.Lines!.Length; // → 2
string? firstProduct = dto.Lines[0].Product; // → "Pen"
string note = dto.Note; // → "none"

To map a list of entities, map each item: orders.Select(order => order.MapTo<OrderDto>()).ToList(). Calling MapTo on a collection with a collection type as TDestination throws ArgumentException.

Name matching and setters​

  • Names match case-insensitively: a source id or NAME fills Id or Name. When two properties differ only in case, the exact-case match wins. When a class hides a base property with new, the property of the most-derived type wins.
  • Destination properties with a public setter or an init setter are written. MapTo creates a brand-new object, as an object initializer does, so init properties and records with init properties are filled.
  • Read-only properties, and properties with a private, protected or internal setter, are left as they are. A private set on Id or IsAdmin is never written.
  • Destination properties with no matching source property keep the value their initializer gave them (Note above).
  • The source can be any object, including an anonymous object: new { Id = "42", Status = "shipped" }.MapTo<Order>().

Value conversion​

Simple values are converted with invariant culture:

Conversions between simple types
Order parsed = new { Id = "42", Status = "shipped" }.MapTo<Order>();

int parsedId = parsed.Id; // → 42
OrderStatus parsedStatus = parsed.Status; // → OrderStatus.Shipped
  • Number to string, string to number, enum to string and string to enum (the enum name is matched ignoring case). Types with a TypeConverter are converted through it.
  • A value that cannot be converted throws InvalidCastException, and the message names the property: "Cannot map property 'Id': 'x' does not convert to 'System.Int32'." An unknown enum name ("Lost") throws the same way.
  • null mapped to a non-nullable value type throws InvalidCastException.
  • A collection or an object mapped to a simple property such as a string throws InvalidCastException.

Nested objects and collections​

  • Nested classes are mapped into new instances of the destination property type (OrderCustomer to CustomerDto). A null nested object stays null. A nested type without a public parameterless constructor throws InvalidOperationException naming the property.
  • Lists, arrays and sets are mapped item by item into new collections: List<OrderLine> to OrderLineDto[], List<T>, IEnumerable<T>, HashSet<T>, Collection<T>, ObservableCollection<T>, LinkedList<T>, and any concrete ICollection<T> with a parameterless constructor. Element types are converted too (List<int> to HashSet<long>). A collection type that cannot be created (for example ReadOnlyCollection<T>) throws InvalidOperationException.
  • Dictionaries of the same type are copied into a new dictionary with the same comparer. The values are copied by reference. A dictionary mapped to a different dictionary type throws InvalidOperationException.
  • Same type, new instance. A nested object of the same type is copied into a new instance when its type has a public parameterless constructor and only auto-properties with public (or init) setters. Otherwise (private fields, private setters, no parameterless constructor) the reference is assigned, so no hidden state is lost.
  • Assigned by reference: Queue<T>, Stack<T>, immutable collections, JsonObject, ExpandoObject, multi-dimensional arrays, and classes that derive from a framework collection and add state of their own (class Team : List<string> with a Name), when the property has the same type.
  • Structs are created and filled. A struct of the same type is copied by value, so the lists and objects inside it are shared with the source.
  • Graphs keep their shape. Each source object, collection and dictionary is mapped once. A back-reference (Employee.Manager and Manager.Reports) or a cycle points to the copy made the first time, so dto.Manager.Reports[0].Manager is dto.Manager.
  • Nesting deeper than 16 levels without a cycle throws InvalidOperationException.

Errors:

SituationException
source is nullArgumentNullException (ParamName "source")
source is a collection and TDestination is a collection typeArgumentException
A value cannot be converted, null into a non-nullable value type, an object into a simple or collection propertyInvalidCastException, naming the property
No parameterless constructor on a nested type, unsupported collection type, different dictionary typeInvalidOperationException, naming the property
Nesting deeper than 16 levelsInvalidOperationException ("Mapping is nested deeper than 16 levels; the objects probably contain a cycle.")
Your own getter, setter, constructor or Add method throwsyour exception, unchanged (not wrapped in TargetInvocationException)

UpdateFrom​

target.UpdateFrom(source, ignoreNullValues = true) copies the matching properties of source onto an object that already exists, with the same rules as MapTo. It returns target, so you can chain it. By default null values in the source are skipped, which gives PATCH semantics: only the fields the client sent change.

PATCH a profile
public sealed class ProfilePatch
{
public string? Name { get; set; }
public bool? IsLocked { get; set; }
public int? Age { get; set; }
}

Profile profile = new Profile { Name = "Alice", IsLocked = true, Age = 30 };

profile.UpdateFrom(new ProfilePatch { Name = "Alice2" });

string? name = profile.Name; // → "Alice2"
bool isLocked = profile.IsLocked; // → true
int age = profile.Age; // → 30
Declare PATCH properties as nullable

Only null means "not sent". A non-nullable value-type property on the source (bool IsLocked, int Age) is always copied, so a PATCH DTO with bool IsLocked that the client leaves out writes false. Use bool?, int? and string? in PATCH DTOs.

Nested objects: deep PATCH​

With the default ignoreNullValues: true, a nested PATCH object is merged into the object the target already holds. Fields the patch does not carry keep their values, and the nested object keeps its identity.

Change one field of a nested address
PostalAddress address = new PostalAddress { City = "Amman", Street = "Main" };
Customer customer = new Customer { Email = "a@x.com", Address = address };

customer.UpdateFrom(new { Address = new { City = "Irbid" } });

bool sameAddress = ReferenceEquals(customer.Address, address); // → true
string? city = customer.Address!.City; // → "Irbid"
string? street = customer.Address.Street; // → "Main"
string? email = customer.Email; // → "a@x.com"
  • When the nested property is null, a PATCH object creates a new instance of the property type (Street then stays null).
  • A nested source value is a PATCH object when it is an anonymous object or is not an instance of the property type (an AddressPatch DTO for a PostalAddress property). A PATCH object is merged.
  • Any other nested value is a complete value. It is merged in place only into an existing object of the same runtime type that has public non-init setters. A value of another runtime type replaces the old one: a target holding a Dog that receives a Cat ends up with a copy of the Cat.
  • Immutable values are replaced by a complete value and left unchanged by a PATCH object: records with only init or positional properties, structs without public setters, tuples, JsonElement, Version, and classes with only get-only properties.
  • Objects of framework types such as CultureInfo and IPAddress are never changed in place. A complete value replaces them and a PATCH object leaves them unchanged.
  • Collections are always replaced by a new mapped collection, never merged.

What UpdateFrom never writes​

  • init setters, on the target and on nested objects that already exist. The object already exists, so init does not apply. (Nested objects that UpdateFrom creates itself do get their init properties.)
  • Private, protected and internal setters.
  • Read-only properties.
init and private setters are protected
Member member = new Member { Role = "user" }; // public string Role { get; init; }

member.UpdateFrom(new { Role = "admin", Name = "Ahmad" });

string role = member.Role; // → "user"
string? memberName = member.Name; // → "Ahmad"

ignoreNullValues: false​

Pass ignoreNullValues: false to copy null values as well and to replace nested objects with new ones instead of merging into them. Use it when the source is a complete replacement (PUT semantics), not a partial update.

Copy nulls too
// Contact: int Id, string? Name, string? Email. ContactPatch: string? Name, string? Email.
Contact contact = new Contact { Id = 3, Name = "Ahmad", Email = "old@example.com" };
ContactPatch patch = new ContactPatch { Email = "new@example.com" };

contact.UpdateFrom(patch, ignoreNullValues: false);

string? name = contact.Name; // → null
string? email = contact.Email; // → "new@example.com"
int id = contact.Id; // → 3

With ignoreNullValues: false, a null source value for a non-nullable target property (a bool? source for a bool target) throws InvalidCastException: "Cannot map property 'IsLocked': null cannot be assigned to the non-nullable type 'System.Boolean'."

Partial updates on failure

UpdateFrom is not transactional. When a property fails (a conversion error, or your own setter enforcing a domain rule), the properties copied before it keep their new values. Your own exceptions propagate unchanged, so you can catch your own exception type. Validate the source first, or update a DeepClone and swap it in on success.

Errors are the same as for MapTo. null for target or source throws ArgumentNullException with ParamName "target" or "source".

Example: an audit log for invoice edits​

This example combines the methods on this page. It takes a snapshot, applies a PATCH request, records what changed, and replays the change onto a read model.

InvoiceService.cs
public sealed record AuditEntry(string InvoiceNumber, string Property, string? OldValue, string? NewValue);

public sealed class InvoicePatch
{
public InvoiceStatus? Status { get; set; }
public decimal? Total { get; set; }
public AddressPatch? BillingAddress { get; set; }
}

public IReadOnlyList<PropertyChange> Edit(Invoice invoice, InvoicePatch patch, List<AuditEntry> auditLog)
{
// 1. Snapshot first. DeepClone shares nothing, so in-place nested updates still show up.
Invoice snapshot = invoice.DeepClone();

// 2. Apply the PATCH: null fields are skipped, BillingAddress is merged in place.
invoice.UpdateFrom(patch);

// 3. Record what changed.
IReadOnlyList<PropertyChange> changes = snapshot.CompareProperties(invoice);
foreach (PropertyChange change in changes)
{
auditLog.Add(new AuditEntry(
invoice.Number!,
change.PropertyName,
change.OldValue?.ToString(),
change.NewValue?.ToString()));
}

return changes;
}
Calling it
Invoice invoice = new Invoice
{
Number = "INV-1001",
Status = InvoiceStatus.Draft,
Total = 120.00m,
BillingAddress = new PostalAddress { City = "Amman", Street = "Rainbow" },
};
Invoice readModel = invoice.DeepClone();
List<AuditEntry> auditLog = new List<AuditEntry>();

InvoicePatch patch = new InvoicePatch
{
Status = InvoiceStatus.Sent,
Total = 135.50m,
BillingAddress = new AddressPatch { City = "Irbid" },
};
IReadOnlyList<PropertyChange> changes = Edit(invoice, patch, auditLog);

int entries = auditLog.Count; // → 3
string summary = string.Join("\n", changes);

// 4. Bring the read model up to date with the same changes.
changes.ApplyTo(readModel);
int stale = readModel.CompareProperties(invoice).Count; // → 0
Output (summary)
Status: Draft -> Sent
Total: 120.00 -> 135.50
BillingAddress.City: "Amman" -> "Irbid"
Why DeepClone for the snapshot

UpdateFrom changes BillingAddress in place. A shallow copy (MemberwiseClone) would share that address with the invoice, so the snapshot would also say "Irbid" and CompareProperties would miss the change. DeepClone gives the snapshot its own address.

AuditEntry stores the values as text through ToString(), which uses the current culture for numbers and dates. Use change.ToString() instead when you want the invariant, quoted form shown above.

See also​

  • Objects: conversion, null checks, property access by name and query strings.
  • JSON and safe logging: canonical JSON, content hashes and masking sensitive data before you log an object.
  • Diff and dependencies: differences between two sequences rather than two objects.
  • Trimming and AOT: what reflection-based helpers need in a trimmed app.