LowCodeHub.ObjectMapper
0.0.6
See the version list below for details.
dotnet add package LowCodeHub.ObjectMapper --version 0.0.6
NuGet\Install-Package LowCodeHub.ObjectMapper -Version 0.0.6
<PackageReference Include="LowCodeHub.ObjectMapper" Version="0.0.6" />
<PackageVersion Include="LowCodeHub.ObjectMapper" Version="0.0.6" />
<PackageReference Include="LowCodeHub.ObjectMapper" />
paket add LowCodeHub.ObjectMapper --version 0.0.6
#r "nuget: LowCodeHub.ObjectMapper, 0.0.6"
#:package LowCodeHub.ObjectMapper@0.0.6
#addin nuget:?package=LowCodeHub.ObjectMapper&version=0.0.6
#tool nuget:?package=LowCodeHub.ObjectMapper&version=0.0.6
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
- Nested Objects
- Collection Mapping
- Map into Existing Object
- Property Flattening
- Enum Mapping
- String ↔ Enum Conversion
- Nullable Handling
- Numeric Type Conversions
- Record Types (Positional Constructors)
- Init-Only Properties
- Required Properties
- Array 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
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.
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
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 list in a single call:
List<OpsRequest> results = ObjectMapper.Map<List<ApiRequest>, List<OpsRequest>>(sourceList);
If the source list is empty, you get an empty list. If it's null, the collection property will be null.
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"
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)
Array Destination Types
When the destination property is an array and the source is a List<T> (or any enumerable), the generated code calls .ToArray():
public class Source { public List<string> Tags { get; set; } }
public class Dest { public string[] Tags { get; set; } }
Dest result = ObjectMapper.Map<Source, Dest>(source);
// result.Tags is a string[] with all elements from source.Tags
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>()calls and fluent chain methods (.Adapt(),.Ignore(),.Set(),.Transform(),.Condition(),.AdaptEach()). - 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.