LowCodeHub.ObjectMapper 0.0.8

dotnet add package LowCodeHub.ObjectMapper --version 0.0.8
                    
NuGet\Install-Package LowCodeHub.ObjectMapper -Version 0.0.8
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LowCodeHub.ObjectMapper" Version="0.0.8" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.ObjectMapper" Version="0.0.8" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.ObjectMapper" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LowCodeHub.ObjectMapper --version 0.0.8
                    
#r "nuget: LowCodeHub.ObjectMapper, 0.0.8"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LowCodeHub.ObjectMapper@0.0.8
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LowCodeHub.ObjectMapper&version=0.0.8
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.ObjectMapper&version=0.0.8
                    
Install as a Cake Tool

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.

NuGet License: MIT

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 buildTransitive MSBuild 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

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 (TT?)

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 a OMAP006 warning)
  • 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

  1. 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()).
  2. 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.).
  3. It emits a C# interceptor — a method with [InterceptsLocation] that replaces the original call at that exact file/line/column.
  4. The interceptor body is a straightforward new Dest { Prop1 = source.Prop1, ... } — pure assignments, zero overhead.
  5. 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 InterceptorsNamespaces MSBuild property is configured automatically by the NuGet package

License

MIT © Ahmed Abuelnour

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.

Version Downloads Last Updated
0.0.8 45 5/12/2026
0.0.7 139 4/23/2026
0.0.6 103 4/19/2026
0.0.5 92 4/19/2026
0.0.4 117 4/16/2026
0.0.3 90 4/16/2026
0.0.2 88 4/16/2026
0.0.1 91 4/16/2026