LowCodeHub.ObjectMapper
0.0.8
dotnet add package LowCodeHub.ObjectMapper --version 0.0.8
NuGet\Install-Package LowCodeHub.ObjectMapper -Version 0.0.8
<PackageReference Include="LowCodeHub.ObjectMapper" Version="0.0.8" />
<PackageVersion Include="LowCodeHub.ObjectMapper" Version="0.0.8" />
<PackageReference Include="LowCodeHub.ObjectMapper" />
paket add LowCodeHub.ObjectMapper --version 0.0.8
#r "nuget: LowCodeHub.ObjectMapper, 0.0.8"
#:package LowCodeHub.ObjectMapper@0.0.8
#addin nuget:?package=LowCodeHub.ObjectMapper&version=0.0.8
#tool nuget:?package=LowCodeHub.ObjectMapper&version=0.0.8
LowCodeHub.ObjectMapper
A zero-overhead object mapper for .NET powered by C# interceptors and incremental source generation. Every mapping call is rewritten at compile time into direct property assignments — no reflection, no expression trees, no runtime cost.
Why This Mapper?
| Feature | LowCodeHub.ObjectMapper | AutoMapper | Mapster |
|---|---|---|---|
| Runtime overhead | Zero — compile-time only | Expression trees | Compiled delegates |
| Reflection | None | Yes | Yes |
| Configuration | Fluent chain at call site | Profile classes | Global settings |
| AOT / trimming | Fully compatible | Problematic | Problematic |
| Diagnostics | OMAP001–007 at build | Runtime exceptions | Runtime exceptions |
Installation
dotnet add package LowCodeHub.ObjectMapper
The NuGet package automatically configures the interceptors namespace via a
buildTransitiveMSBuild props file. No extra setup is needed.
Quick Start
using LowCodeHub.ObjectMapper;
// Map a source object to a new destination — properties matched by name
UserDto dto = ObjectMapper.Map<User, UserDto>(user);
That's it. The source generator rewrites this call at compile time into:
// What actually executes at runtime (generated code):
var __dest = new UserDto
{
Name = user.Name,
Email = user.Email,
// ... every matching property
};
Table of Contents
- Basic Mapping
- Fluent API
- Adapt — Rename Properties
- Ignore — Exclude Properties
- Set — Override with a Value
- Transform — Compute from Source
- Condition — Conditional Mapping
- Condition Fallback — Use an Else Value
- NullSubstitute — Replace Null Destination Values
- AdaptEach — Per-Element Collection Post-Processing
- AfterMap — Post-Mapping Action
- Chaining Multiple Operations
- Attribute Mapping
- Nested Objects
- Collection Mapping
- Dictionary Mapping
- Clone
- Map into Existing Object
- Property Flattening
- Enum Mapping
- String ↔ Enum Conversion
- String ↔ Common Type Conversion
- DateTime ↔ DateTimeOffset Conversion
- Nullable Handling
- Numeric Type Conversions
- Record Types (Positional Constructors)
- Init-Only Properties
- Required Properties
- Collection Destination Types
- Circular Reference Safety
- Analyzer Diagnostics
- Code Fix: Auto-Ignore Unmapped Properties
- How It Works
- Requirements
- License
Basic Mapping
Properties are matched by name (case-insensitive). Only properties with a public getter on the source and a public setter on the destination are mapped.
public class ApiRequest
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
public class OpsRequest
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int AgeInYears { get; set; } // different name — not auto-mapped
}
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(apiRequest);
// result.FirstName = "John"
// result.LastName = "Doe"
// result.AgeInYears = 0 (not mapped — name doesn't match)
Fluent API
ObjectMapper.Map<S, D>() returns a MappingResult<S, D> struct that exposes a fluent API. It implicitly converts to D, so you can assign directly:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Adapt(s => s.Age, d => d.AgeInYears)
.Set(d => d.LastName, "Override")
.Transform(d => d.FirstName, s => s.FirstName.ToUpper());
Adapt — Rename Properties
Map a source property to a differently-named destination property:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Adapt(s => s.Age, d => d.AgeInYears);
// result.AgeInYears = 30
Ignore — Exclude Properties
Reset a destination property to its default value, even if it was auto-mapped:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Ignore(d => d.LastName);
// result.LastName = null (default for string)
Set — Override with a Value
Assign a specific value (literal or expression) to a destination property:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Set(d => d.AgeInYears, 42);
// result.AgeInYears = 42
// Works with any expression:
var computedAge = source.Age * 2;
OpsRequest result2 = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Set(d => d.AgeInYears, computedAge);
// result2.AgeInYears = 60
Transform — Compute from Source
Derive a destination property from the source using any expression:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Transform(d => d.FirstName, s => $"{s.FirstName} {s.LastName}");
// result.FirstName = "John Doe"
Condition — Conditional Mapping
Keep a mapped value only if a predicate is true; otherwise reset to default:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Adapt(s => s.Age, d => d.AgeInYears)
.Condition(d => d.AgeInYears, s => s.Age > 18);
// If Age > 18 → AgeInYears = 30
// If Age ≤ 18 → AgeInYears = 0
Condition Fallback — Use an Else Value
Assign a specific fallback value when the predicate is false:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Adapt(s => s.Age, d => d.AgeInYears)
.Condition(d => d.AgeInYears, s => s.Age >= 18, fallback: -1);
// If Age >= 18 → AgeInYears = source.Age
// If Age < 18 → AgeInYears = -1
NullSubstitute — Replace Null Destination Values
Replace a nullable reference destination value after mapping:
public class Source { public string? Name { get; set; } }
public class Dest { public string Name { get; set; } = ""; }
Dest result = ObjectMapper.Map<Source, Dest>(new Source { Name = null })
.NullSubstitute(d => d.Name, "Unknown");
// result.Name = "Unknown"
AdaptEach — Per-Element Collection Post-Processing
Run a custom action on each paired element in matching source/destination collections. Useful when element types have different property names or need conversion logic:
public class SrcItem { public string Label { get; set; } public decimal ValueDecimal { get; set; } }
public class DstItem { public string Label { get; set; } public int ValueInt { get; set; } }
var result = ObjectMapper.Map<Order, OrderDto>(order)
.AdaptEach(
s => s.Items, // source collection
d => d.Items, // destination collection
(srcEl, dstEl) =>
{
dstEl.ValueInt = (int)decimal.Truncate(srcEl.ValueDecimal);
});
The action runs after the base mapping, so dstEl.Label is already mapped. Use AdaptEach only for the properties that need custom logic.
AfterMap — Post-Mapping Action
Run a final action with both the source and destination:
public class Source { public int A { get; set; } public int B { get; set; } }
public class Dest { public int A { get; set; } public int B { get; set; } public int Sum { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(source)
.AfterMap((s, d) => d.Sum = s.A + s.B);
Chaining Multiple Operations
All fluent methods return MappingResult<S, D>, so they can be chained in any order:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Adapt(s => s.Age, d => d.AgeInYears)
.Set(d => d.LastName, "ChainedLast")
.Transform(d => d.FirstName, s => s.FirstName.ToUpper())
.Condition(d => d.AgeInYears, s => s.Age > 0);
// FirstName = "JOHN", LastName = "ChainedLast", AgeInYears = 30
Attribute Mapping
Use attributes on destination properties when a mapping rule should live with the DTO instead of every call site.
MapFrom
[MapFrom] maps from a different source property name, or from a dotted source path:
public class User
{
public string FirstName { get; set; } = "";
public Address Address { get; set; } = new();
}
public class UserDto
{
[MapFrom("FirstName")]
public string GivenName { get; set; } = "";
[MapFrom("Address.City")]
public string HomeCity { get; set; } = "";
}
MapIgnore
[MapIgnore] skips a destination property for every map into that type:
public class UserDto
{
public string Name { get; set; } = "";
[MapIgnore]
public string InternalNote { get; set; } = "";
}
Nested Objects
Nested objects are mapped recursively when the property names match:
public class ApiRequest
{
public string Name { get; set; }
public ApiAddress Address { get; set; } // nested
}
public class OpsRequest
{
public string Name { get; set; }
public OpsAddress Address { get; set; } // different type, same property name
}
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source);
// result.Address.City = source.Address.City
// result.Address.Street = source.Address.Street
If source.Address is null, result.Address will be null — no NullReferenceException.
Collection Mapping
Matching collection properties
List<T> properties are mapped element-by-element when the property names match:
public class Source { public List<SrcTag> Tags { get; set; } }
public class Dest { public List<DstTag> Tags { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(source);
// Each SrcTag is mapped to a new DstTag by matching property names
Top-level collection mapping
Map an entire sequence in a single call:
List<OpsRequest> results = ObjectMapper.Map<List<ApiRequest>, List<OpsRequest>>(sourceList);
List<OpsRequest> list = ObjectMapper.MapList<ApiRequest, OpsRequest>(sourceList);
OpsRequest[] array = ObjectMapper.MapArray<ApiRequest, OpsRequest>(sourceList);
HashSet<OpsRequest> set = ObjectMapper.MapSet<ApiRequest, OpsRequest>(sourceList);
If the source sequence is empty, you get an empty destination collection.
Dictionary Mapping
Dictionaries are copied as dictionaries instead of being treated as IEnumerable<KeyValuePair<TKey, TValue>>:
public class Source { public Dictionary<string, int> Counts { get; set; } = new(); }
public class Dest { public Dictionary<string, int> Counts { get; set; } = new(); }
Dest result = ObjectMapper.Map<Source, Dest>(source);
// result.Counts is a new dictionary with the same key/value pairs
Dictionary<TKey, TValue>, IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue>, and ImmutableDictionary<TKey, TValue> destinations are supported when key and value types match.
Clone
Clone<T>() is shorthand for self-mapping a type:
User copy = ObjectMapper.Clone(user);
// Same mapped values, different instance for class types
The clone path uses the same generated mapping logic as Map<T, T>(), including nested objects and collections.
Map into Existing Object
Populate an existing destination object instead of creating a new one. Properties that already have values and don't match source properties remain unchanged:
var existing = new OpsRequest { FirstName = "OLD", AgeInYears = 99 };
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source, existing);
// result.FirstName = "John" (overwritten by source)
// result.AgeInYears = 99 (preserved — no matching source property)
Property Flattening
Properties named using the pattern {NestedObjectName}{PropertyName} on the destination are automatically mapped from nested source properties:
public class Source
{
public string FirstName { get; set; }
public SourceAddress Address { get; set; }
}
public class SourceAddress
{
public string City { get; set; }
public string Street { get; set; }
}
public class FlatDto
{
public string FirstName { get; set; }
public string AddressCity { get; set; } // ← mapped from source.Address.City
public string AddressStreet { get; set; } // ← mapped from source.Address.Street
}
FlatDto result = ObjectMapper.Map<Source, FlatDto>(source);
// result.AddressCity = "Cairo"
If the intermediate object (source.Address) is null, the flattened properties receive their default value.
Enum Mapping
Enums with different types but matching member names are mapped automatically:
public enum SourceStatus { Inactive, Active, Suspended }
public enum DestStatus { Active, Inactive, Suspended }
public class Source { public SourceStatus Status { get; set; } }
public class Dest { public DestStatus Status { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(source);
// SourceStatus.Active → DestStatus.Active (matched by name)
If a source enum member name doesn't exist in the destination enum, the destination gets default(DestEnum):
// MismatchLevel.Legendary has no match in DestMismatchLevel
// result.Level = DestMismatchLevel.None (default, first member)
String ↔ Enum Conversion
String → Enum
A string source property is parsed into a destination enum using Enum.TryParse:
public class Source { public string Status { get; set; } }
public class Dest { public MyStatus Status { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(new Source { Status = "Active" });
// result.Status = MyStatus.Active
// If the string doesn't match any enum member:
Dest result2 = ObjectMapper.Map<Source, Dest>(new Source { Status = "NonExistent" });
// result2.Status = default(MyStatus)
Enum → String
An enum source property is converted to its string name via .ToString():
public class Source { public MyStatus Status { get; set; } }
public class Dest { public string Status { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(new Source { Status = MyStatus.Suspended });
// result.Status = "Suspended"
String ↔ Common Type Conversion
Strings are parsed into common framework types when the destination property has the same name:
public class Source
{
public string Id { get; set; } = "";
public string Birthday { get; set; } = "";
public string Site { get; set; } = "";
}
public class Dest
{
public Guid Id { get; set; }
public DateOnly Birthday { get; set; }
public Uri? Site { get; set; }
}
Dest result = ObjectMapper.Map<Source, Dest>(source);
Supported string parse targets:
| Target Type |
|---|
Guid |
DateTime |
DateTimeOffset |
DateOnly |
TimeOnly |
TimeSpan |
Uri |
Version |
The reverse direction is also supported via .ToString():
public class Source { public Guid Id { get; set; } }
public class Dest { public string Id { get; set; } = ""; }
DateTime ↔ DateTimeOffset Conversion
DateTime and DateTimeOffset properties are converted in both directions, including nullable forms and record constructor parameters:
public class Source
{
public DateTime Started { get; set; }
public DateTimeOffset Finished { get; set; }
}
public record Dest(DateTimeOffset Started, DateTime Finished);
Dest result = ObjectMapper.Map<Source, Dest>(source);
Generated mappings use new DateTimeOffset(dateTime) for DateTime → DateTimeOffset, and .DateTime for DateTimeOffset → DateTime.
Nullable Handling
Nullable unwrap (T? → T)
When the source property is nullable and the destination is not:
public class Source { public int? Age { get; set; } }
public class Dest { public int Age { get; set; } }
// With value: result.Age = 42
// With null: result.Age = 0 (default)
Nullable wrap (T → T?)
When the source property is non-nullable and the destination is nullable:
public class Source { public int Age { get; set; } }
public class Dest { public int? Age { get; set; } }
// result.Age = 99 (wrapped to int?)
Numeric Type Conversions
Different numeric types are converted automatically with explicit casts:
public class Source
{
public int IntVal { get; set; }
public float FloatVal { get; set; }
public decimal DecimalVal { get; set; }
public byte ByteVal { get; set; }
}
public class Dest
{
public long IntVal { get; set; } // int → long
public double FloatVal { get; set; } // float → double
public int DecimalVal { get; set; } // decimal → int (truncated)
public int ByteVal { get; set; } // byte → int
}
Dest result = ObjectMapper.Map<Source, Dest>(source);
All combinations of nullable/non-nullable numeric conversions are supported:
| Source | Destination | Generated Code |
|---|---|---|
int |
long |
(long)source.IntVal |
int? |
long |
source.IntVal.HasValue ? (long)source.IntVal.GetValueOrDefault() : default |
int |
long? |
(long?)source.IntVal |
decimal? |
int? |
source.Value.HasValue ? (int?)source.Value.GetValueOrDefault() : default |
Supported numeric types: byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal.
Record Types (Positional Constructors)
Records and types without a parameterless constructor are supported. The generator finds the best constructor and maps parameters by name:
public record UserDto(string Name, int Age);
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
UserDto dto = ObjectMapper.Map<User, UserDto>(user);
// Generates: new UserDto(user.Name, user.Age)
Constructor parameters are matched to source properties by name (case-insensitive). Any remaining settable properties on the destination are assigned via object initializer syntax.
All record styles are supported:
// Positional record
public record UserDto(string Name, int Age);
// Record with init-only properties
public record UserDto { public string Name { get; init; } public int Age { get; init; } }
// Record with mutable properties
public record UserDto { public string Name { get; set; } public int Age { get; set; } }
// Mixed: positional + additional init properties
public record UserDto(string Name, int Age) { public string Tag { get; init; } = ""; }
Init-Only Properties
Properties declared with { get; init; } are fully supported. The mapper handles them differently depending on the operation:
Map() — Object Initializer
Map() creates new objects via object initializer syntax, so init properties are assigned naturally:
public class Dest { public string Name { get; init; } = ""; public int Age { get; init; } }
Dest result = ObjectMapper.Map<Source, Dest>(source); // ✅ Works
Fluent API (.Set(), .Adapt(), .Ignore(), .Transform(), .Condition())
Since init-only properties cannot be assigned after construction, the mapper uses special strategies:
| Destination Type | Strategy | How It Works |
|---|---|---|
Record with { get; init; } |
with expression |
dest with { Prop = value } — efficient single copy |
Class with { get; init; } |
New object + initializer | Creates a new instance copying all properties, overriding the target |
Class without parameterless ctor + { get; init; } |
OMAP007 error | Not possible to reconstruct safely |
Record example:
public record UserDto { public string Name { get; init; } = ""; public string Tag { get; init; } = ""; }
UserDto result = ObjectMapper.Map<User, UserDto>(user)
.Set(d => d.Tag, "VIP") // ✅ uses: dest with { Tag = "VIP" }
.Transform(d => d.Name, s => s.Name.ToUpper()); // ✅ uses: dest with { Name = ... }
Class example:
public class UserDto { public string Name { get; init; } = ""; public string Tag { get; init; } = ""; }
UserDto result = ObjectMapper.Map<User, UserDto>(user)
.Set(d => d.Tag, "VIP"); // ✅ creates new UserDto { Name = dest.Name, Tag = "VIP" }
Map(source, existing) — Existing Object
When mapping into an existing object, init-only properties are skipped since they can't be reassigned after construction. Only { get; set; } properties are updated.
Required Properties
C# required properties are handled correctly:
- Matched required properties are mapped normally from the source
- Unmapped required properties are set to
default!so the code compiles (with aOMAP006warning) - You can use
.Set()to provide explicit values for unmapped required properties:
public class Dest
{
public required string Name { get; set; }
public required string Role { get; set; } // no matching source property
}
// Without .Set() — Role gets default! with OMAP006 warning
Dest result = ObjectMapper.Map<Source, Dest>(source);
// With .Set() — explicit value, no warning
Dest result = ObjectMapper.Map<Source, Dest>(source)
.Set(d => d.Role, "Admin");
Required properties on records with { get; init; } also work:
public record Dest { public required string Name { get; init; } public required string Extra { get; init; } }
Dest result = ObjectMapper.Map<Source, Dest>(source);
// Name mapped from source, Extra = default! (with OMAP006 warning)
Collection Destination Types
Collection properties are materialized to match the destination shape:
public class Source
{
public List<string> Tags { get; set; }
public List<string> UniqueTags { get; set; }
public List<string> ImmutableTags { get; set; }
}
public class Dest
{
public string[] Tags { get; set; }
public HashSet<string> UniqueTags { get; set; }
public ImmutableArray<string> ImmutableTags { get; set; }
}
Supported destination materializers include:
| Destination | Materializer |
|---|---|
T[] |
.ToArray() |
List<T> and most IEnumerable<T> destinations |
.ToList() |
HashSet<T>, ISet<T>, IReadOnlySet<T> |
.ToHashSet() |
ImmutableArray<T> |
.ToImmutableArray() |
ImmutableList<T> |
.ToImmutableList() |
ImmutableHashSet<T> |
.ToImmutableHashSet() |
IReadOnlyCollection<T>, IReadOnlyList<T> |
.ToArray() |
Circular Reference Safety
Self-referencing types are detected at compile time. The generator maps one level deep and breaks the cycle:
public class TreeNode
{
public string Name { get; set; }
public TreeNode? Child { get; set; }
}
public class TreeNodeDto
{
public string Name { get; set; }
public TreeNodeDto? Child { get; set; }
}
TreeNodeDto result = ObjectMapper.Map<TreeNode, TreeNodeDto>(root);
// Maps root.Child recursively without stack overflow
Analyzer Diagnostics
The source generator reports diagnostics at build time to help you catch mapping issues early:
| Code | Severity | Description |
|---|---|---|
| OMAP001 | Warning | Open generic type arguments (Map<T, U>) are not supported — use concrete types |
| OMAP002 | Warning | No matching properties found between source and destination types |
| OMAP003 | Info | Mapping summary showing how many properties were mapped and listing unmapped ones |
| OMAP004 | Error | Invalid lambda expression in .Adapt() / .Ignore() — must be x => x.Property |
| OMAP005 | Warning | Destination property has no public setter and was skipped |
| OMAP006 | Warning | A required property has no matching source property and will be set to default |
| OMAP007 | Error | Fluent operation cannot assign to an init-only property on a non-record class without a parameterless constructor |
Example build output:
info OMAP003: Mapped 3 of 5 destination properties from 'ApiRequest' to 'OpsRequest'; unmapped: AgeInYears, InternalId
You can suppress diagnostics in your project file:
<NoWarn>$(NoWarn);OMAP003</NoWarn>
Code Fix: Auto-Ignore Unmapped Properties
When OMAP003 reports unmapped properties, a lightbulb code fix is available in your IDE that automatically adds .Ignore() calls for each unmapped property:
Before (with OMAP003 info diagnostic):
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source);
After applying the code fix:
OpsRequest result = ObjectMapper.Map<ApiRequest, OpsRequest>(source)
.Ignore(x => x.AgeInYears)
.Ignore(x => x.InternalId);
This silences the diagnostic and makes your mapping intent explicit.
How It Works
- At build time, the incremental source generator scans your code for
ObjectMapper.Map<S, D>(),MapList,MapArray,MapSet,Clone, and fluent chain methods (.Adapt(),.Ignore(),.Set(),.Transform(),.Condition(),.AdaptEach(),.AfterMap(),.NullSubstitute()). - For each call site, it analyzes the source and destination types, matches properties by name, and classifies each match (direct, nested, collection, flattened, enum, nullable, numeric, etc.).
- It emits a C# interceptor — a method with
[InterceptsLocation]that replaces the original call at that exact file/line/column. - The interceptor body is a straightforward
new Dest { Prop1 = source.Prop1, ... }— pure assignments, zero overhead. - At runtime, the original
ObjectMapper.Map()method body (throw new InvalidOperationException(...)) is never reached because the interceptor takes over.
┌─────────────────┐ compile time ┌──────────────────────────┐
│ Your Code │ ─────────────────► │ Generated Interceptor │
│ Map<A, B>(src) │ │ new B { X = src.X, ... } │
└─────────────────┘ └──────────────────────────┘
│ │
└──── at runtime, interceptor runs ─────────┘
Requirements
- .NET 10 or later (uses C# 14 interceptors)
- The
InterceptorsNamespacesMSBuild property is configured automatically by the NuGet package
License
MIT © Ahmed Abuelnour
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.