Smart.Mapper is a high-performance object mapper library based on Roslyn Incremental Source Generator.
It automatically generates property-copying code at compile time for static partial methods decorated with the [Mapper] attribute.
- Zero overhead - no reflection at runtime; all code is generated statically at compile time
- Specialized-method dispatch -
ConvertTo{TargetType}naming convention enables direct-call generation, friendly to JIT inlining - Per-method declaration -
[Mapper]is placed on individual methods, so mapper methods feel like ordinary helper functions - Custom parameter passthrough - additional arguments such as
Map(Src, Dst, TContext ctx)are passed on to the[MapUsing],Converter,[MapCondition],[BeforeMap]and[AfterMap]methods that declare them, and can be used in[MapExpression] - NativeAOT / trimming fully supported -
<IsAotCompatible>true</IsAotCompatible>declared; NativeAOT smoke test passes - Rich diagnostics - 35 compile-time diagnostics in phase-based bands (SMP0001βSMP0501)
dotnet add package Usa.Smart.Mapper
The package includes the source generator DLL under analyzers/dotnet/cs, so the generator is activated automatically when you reference the package - no additional setup required.
| Library | Frameworks |
|---|---|
Smart.Mapper |
net10.0, net9.0, net8.0 |
Smart.Mapper.Generator |
netstandard2.0 (Roslyn Incremental Source Generator) |
// Define mapper in a static partial class
internal static partial class ObjectMapper
{
// void pattern: map into an existing instance
[Mapper]
public static partial void Map(Source source, Destination destination);
// return pattern: create and return a new instance
[Mapper]
public static partial Destination Map(Source source);
}public static partial void Map(Source source, Destination destination)
{
destination.Id = source.Id;
destination.Name = source.Name;
destination.Description = source.Description;
}public static partial Destination Map(Source source)
{
var __d = new Destination();
__d.Id = source.Id;
__d.Name = source.Name;
__d.Description = source.Description;
return __d;
}A [Mapper] method can be declared as an extension method. The generated implementation keeps the this modifier, so the mapper reads naturally at the call site.
public static partial class ObjectMapper
{
[Mapper]
public static partial Destination ToDestination(this Source source);
}
var destination = source.ToDestination();| Attribute | Description |
|---|---|
[Mapper] |
Marks a method as a mapping method |
[Mapper(AutoMap = false)] |
Disables automatic same-name property mapping |
[Mapper(Strict = true)] |
Emits SMP0501 warning for unmapped destination properties |
[Mapper(NameComparison = ...)] |
Property name comparison mode, applied to auto-mapping and to the names written in mapping attributes (default: Ordinal) |
[Mapper(Culture = "...")] |
Culture used for type conversion (e.g., "ja-JP") |
[Mapper(DateTimeFormat = "...")] |
Format string for DateTime <-> string conversion (use with Culture) |
[Mapper(NumberFormat = "...")] |
Format string for numeric <-> string conversion (use with Culture) |
[MapProperty] |
Explicit property-to-property mapping; supports NullValue, NullBehavior, Culture, DateTimeFormat, NumberFormat, Converter |
[MapProperty<T>] |
Type-safe variant of [MapProperty] (C# 11+) |
[MapUsing] |
Calculates a value via a static method (custom-parameter aware) |
[MapFrom] |
Maps from a source instance-method call or dot-notation property path |
[MapConstant] |
Sets a constant value on a destination property |
[MapConstant<T>] |
Type-safe variant of [MapConstant] (C# 11+) |
[MapExpression] |
Embeds an arbitrary C# expression (e.g., "System.DateTime.Now") |
[MapIgnore] |
Excludes a destination property from mapping |
[BeforeMap] |
Callback invoked before mapping |
[AfterMap] |
Callback invoked after mapping |
[MapCondition] |
Maps a destination property only when a condition method returns true |
[MapCollection] |
Collection property mapping via an explicit mapper method; supports Strategy, Converter |
[MapNested] |
Nested object mapping via an explicit mapper method |
[ValueConverter] |
Custom type converter (method / class level); supports Method |
[CollectionConverter] |
Custom collection converter (method / class level) |
First argument convention - For the attributes that map a destination member, the first argument is the destination (target) name. The second is the source for
[MapProperty],[MapFrom],[MapCollection]and[MapNested], and the method, constant or expression for[MapUsing],[MapCondition],[MapConstant]and[MapExpression].
| Attribute | Description |
|---|---|
[MapperProfile] |
Sets defaults (Strict, NameComparison, Culture, DateTimeFormat, NumberFormat) for all [Mapper] methods in the class; method-level settings take precedence |
[ValueConverter] |
Default custom type converter for all [Mapper] methods in the class |
[CollectionConverter] |
Default custom collection converter for all [Mapper] methods in the class |
Same-name, compatible-type properties are mapped automatically.
[Mapper]
public static partial void Map(Source source, Destination destination);[Mapper]
[MapProperty(nameof(Destination.FullName), nameof(Source.Name))]
public static partial void Map(Source source, Destination destination);The source name may be omitted, in which case it defaults to the target name. This is handy when only an option needs to be set on an otherwise same-named pair:
[Mapper]
[MapProperty(nameof(Destination.Amount), Culture = "en-US", NumberFormat = "C")]
public static partial void Map(Source source, Destination destination);[MapNested] and [MapCollection] follow the same rule.
A name that cannot be resolved is reported (SMP0213 for the source, SMP0214 for the target) rather than silently dropped.
NameComparison applies to the names written in mapping attributes, not just to auto-mapping. An exact match always wins; the configured comparison is only a fallback, so the default (Ordinal) behaves as before.
public class Src { public int other { get; set; } }
public class Dst { public int Value { get; set; } }
[Mapper(NameComparison = StringComparison.OrdinalIgnoreCase)]
[MapProperty("value", "Other")] // neither spelling matches the declaration exactly
public static partial Dst Map(Src src);Generated code β both names resolve to the declared members:
__d.Value = src.other;This holds for every mapping attribute, including target-only ones such as [MapIgnore].
[Mapper]
[MapProperty(nameof(Destination.Name), nameof(Source.Name), NullValue = "Unknown")]
[MapProperty(nameof(Destination.Count), nameof(Source.Count), NullValue = 0)]
public static partial void Map(Source source, Destination destination);With NullBehavior.Skip, a null source leaves the destination member as it is instead of assigning a value:
// Source: string? Name, int? Count / Destination: string Name, string Count
[Mapper]
[MapProperty(nameof(Destination.Name), NullBehavior = NullBehavior.Skip)]
[MapProperty(nameof(Destination.Count), NullBehavior = NullBehavior.Skip)]
public static partial void Map(Source source, Destination destination);Generated code:
if (source.Name is not null)
{
destination.Name = source.Name!;
}
if (source.Count is not null)
{
destination.Count = DefaultValueConverter.ConvertToString(source.Count.GetValueOrDefault());
}A member assigned through a constructor or an object initializer has no previous value to keep, so NullBehavior.Skip is rejected there (SMP0215).
[Mapper]
[MapIgnore(nameof(Destination.InternalId))]
[MapIgnore(nameof(Destination.TempValue))]
public static partial void Map(Source source, Destination destination);[Mapper]
[MapUsing(nameof(Destination.FullName), nameof(CombineFullName))]
public static partial void Map(Source source, Destination destination);
private static string CombineFullName(Source source) => $"{source.FirstName} {source.LastName}";Custom parameters are automatically forwarded:
[Mapper]
[MapUsing(nameof(Destination.FullName), nameof(CombineFullName))]
public static partial Destination Map(Source source, FormattingContext context);
private static string CombineFullName(Source source, FormattingContext context)
=> $"{source.FirstName}{context.Separator}{source.LastName}";The Converter of [MapProperty], [MapCondition] and [BeforeMap] / [AfterMap] methods receive them the same way when they declare them after their usual parameters, and a [MapExpression] can refer to them by name. The mapper methods of [MapCollection] / [MapNested] and the methods of [ValueConverter] / [CollectionConverter] classes do not receive them.
[Mapper]
[MapFrom(nameof(Destination.ItemCount), nameof(Source.GetItemCount))] // instance method call
[MapFrom(nameof(Destination.NestedValue), "Nested.Value")] // dot-notation path
public static partial void Map(Source source, Destination destination);[Mapper]
[MapConstant<int>("Version", 1)]
[MapConstant<string>("Status", "Active")]
[MapConstant<bool>("IsEnabled", true)]
public static partial void Map(Source source, Destination destination);Non-generic variant: [MapConstant("Status", "Active")]
For expressions: [MapExpression("CreatedAt", "System.DateTime.Now")]
An expression is compiled as a static local function that takes the mapper's parameters under the same names, so it can refer to them (e.g. "source.Price * source.Quantity"), and variables it declares with out var or patterns do not clash with those of other expressions.
[Mapper]
[BeforeMap(nameof(BeforeMapping))]
[AfterMap(nameof(AfterMapping))]
public static partial void Map(Source source, Destination destination);
private static void BeforeMapping(Source source, Destination destination) { /* ... */ }
private static void AfterMapping(Source source, Destination destination) { /* ... */ }The destination property is assigned only when the condition method, which takes the source value (and the custom parameters), returns true.
[Mapper]
[MapCondition(nameof(Destination.Name), nameof(ShouldMapName))]
public static partial void Map(Source source, Destination destination);
private static bool ShouldMapName(string? name) => !string.IsNullOrEmpty(name);[Mapper(AutoMap = false)]
[MapProperty(nameof(Destination.Id), nameof(Source.Id))]
public static partial void Map(Source source, Destination destination);
// Only 'Id' is mapped; other properties are ignored.[MapProperty], [MapConstant], [MapExpression], [MapUsing], [MapFrom], [MapNested] and [MapCollection] take Order. Assignments of the same kind are emitted in ascending Order (0 by default), then in the order they are declared:
[Mapper(AutoMap = false)]
[MapConstant(nameof(Destination.Label), "second", Order = 2)]
[MapConstant(nameof(Destination.Note), "first", Order = 1)]
public static partial void Map(Source source, Destination destination);Generated code:
destination.Note = "first";
destination.Label = "second";The kinds follow a fixed sequence between [BeforeMap] and [AfterMap]: property mappings (auto-mapping and [MapProperty], those guarded by a null check of a source path last), [MapConstant], [MapExpression], [MapUsing], [MapFrom], [MapNested], then [MapCollection]. Order does not move an assignment across kinds.
Use dot notation in [MapProperty] to flatten or unflatten nested properties.
[Mapper]
[MapProperty("ChildId", "Child.Id")]
[MapProperty("ChildName", "Child.Name")]
public static partial void Map(Source source, Destination destination);Generated code adds a null guard for nullable intermediate objects:
if (source.Child is not null)
{
destination.ChildId = source.Child.Id;
destination.ChildName = source.Child.Name;
}[Mapper]
[MapProperty("Child1.Value", "Value1")]
[MapProperty("Child2.Value", "Value2")]
public static partial void Map(Source source, Destination destination);Intermediate destination objects are auto-instantiated:
destination.Child1 ??= new DestinationChild();
destination.Child2 ??= new DestinationChild();
destination.Child1.Value = source.Value1;
destination.Child2.Value = source.Value2;An explicit mapper method must be specified.
internal static partial class ObjectMapper
{
[Mapper]
public static partial DestinationChild MapChild(SourceChild source);
[Mapper]
[MapCollection(nameof(Destination.Children), nameof(Source.Children), Mapper = nameof(MapChild))]
public static partial void Map(Source source, Destination destination);
}Generated code (for a List<SourceChild> source and a List<DestinationChild> target):
{
var __src = CollectionsMarshal.AsSpan(source.Children);
var __list = new List<DestinationChild>(__src.Length);
CollectionsMarshal.SetCount(__list, __src.Length);
var __dst = CollectionsMarshal.AsSpan(__list);
for (var __i = 0; __i < __src.Length; __i++)
{
__dst[__i] = MapChild(__src[__i]);
}
destination.Children = __list;
}The loop is generated inline, shaped by the source and target collection types. A null source collection sets the target to default. The target has to take the collection the loop builds: a List<T> for List<T> and its interfaces, an array, a HashSet<T> for sets, or the immutable or frozen collection of its type. A collection class of its own, such as ObservableCollection<T>, is reported (SMP0217) unless a collection converter builds it. A void element mapper (SourceChild, DestinationChild) fills a new DestinationChild(), so the element type has to be creatable with new() (SMP0210 otherwise).
With a collection converter ([CollectionConverter], see below), its method is called instead, as in CustomCollectionConverter.ToList<SourceChild, DestinationChild>(source.Children, MapChild)!. Converter on [MapCollection] names the method to call, on the [CollectionConverter] type or, without one, on DefaultCollectionConverter, which provides such methods (ToList, ToArray, ToHashSet, ToImmutableArray, ...) for both function-mapper and action-mapper variants.
By default the target gets a new collection. CollectionStrategy.InPlace keeps the target instance, clears it and adds the mapped elements, which preserves a reference held elsewhere:
[Mapper(AutoMap = false)]
[MapCollection(nameof(Destination.Children), Mapper = nameof(MapChild), Strategy = CollectionStrategy.InPlace)]
public static partial void Map(Source source, Destination destination);Generated code (for a List<SourceChild> source and a List<DestinationChild> target):
{
if (destination.Children is null)
{
destination.Children = new List<DestinationChild>(source.Children.Count);
}
destination.Children.Clear();
destination.Children.EnsureCapacity(source.Children.Count);
var __srcSpan = CollectionsMarshal.AsSpan(source.Children);
var __dstColl = destination.Children;
for (var __i = 0; __i < __srcSpan.Length; __i++)
{
__dstColl.Add(MapChild(__srcSpan[__i]));
}
}A null target gets a new List<T> (a HashSet<T> for HashSet<T> / ISet<T>), so the target has to be a settable property that takes it, such as List<T>, IList<T>, ICollection<T>, IReadOnlyList<T>, HashSet<T> or ISet<T> (SMP0217 otherwise, SMP0212 without a setter). A target declared as an interface is filled through ICollection<T>, so its instance has to be mutable. InPlace always emits the loop; a collection converter is not used.
[Mapper]
[MapNested(nameof(Destination.Child), nameof(Source.Child), Mapper = nameof(MapChild))]
public static partial void Map(Source source, Destination destination);Generated code:
destination.Child = source.Child is not null ? MapChild(source.Child!) : default!;When the destination type is a record or has a primary constructor, the generator automatically uses constructor-call syntax.
The parameterized constructor (the longest declared one) is called only when construction requires it: the type is a record, some parameter has no settable matching property, or no public parameterless constructor exists. Otherwise the generator emits new Dst() plus property assignments, with init-only members assigned in the object initializer. A void mapper never constructs, so a convenience parameterized constructor on the destination does not affect it.
public record DestModel(int Id, string Name);
[Mapper]
public static partial DestModel Map(SrcModel src);Generated code:
public static partial DestModel Map(SrcModel src)
{
var __d = new DestModel(src.Id, src.Name);
return __d;
}A
voidmapper cannot assigninit-only members, such as the properties of a positionalrecord(SMP0302).
Constructor arguments go through the same conversion pipeline as ordinary property assignments, so type conversion, Converter, NullValue and Culture / format settings all apply. The same holds for init-only members assigned in the object initializer.
public class Src { public int? Value { get; set; } }
public record Dst(string Value);
[Mapper]
public static partial Dst Map(Src src);Generated code:
var __d = new Dst(src.Value is not null
? DefaultValueConverter.ConvertToString(src.Value.GetValueOrDefault())
: default!);When a nullable source is null, the argument falls back to the destination type's default β or to NullValue when one is specified, or to null when the target is nullable. A nullable intermediate segment in a dotted source path is guarded the same way (src.Child is not null ? ... : default!).
Statement-only options cannot apply to a constructor argument: [MapCondition] has no way to leave the member unassigned, and NullBehavior.Skip has no previous value to keep. Both are rejected with SMP0215.
A get-only property assigned through a constructor can also be remapped:
public class Src { public int Other { get; set; } }
public class Dst
{
public string Value { get; }
public Dst(string value) { Value = value; }
}
[Mapper]
[MapProperty(nameof(Dst.Value), nameof(Src.Other))]
public static partial Dst Map(Src src);A constructor parameter with no matching destination property is supported the same way. Target it with [MapProperty] using the parameter name to remap it:
public class Src { public int Other { get; set; } }
public class Dst
{
public Dst(string value) { Text = value; }
public string Text { get; }
}
[Mapper]
[MapProperty("value", nameof(Src.Other))] // "value" is the constructor parameter name
public static partial Dst Map(Src src);| Source type | Destination type | Behavior |
|---|---|---|
T? |
T? |
Copied as-is (including null) |
T? |
T (leaf) |
default! assigned when null |
T |
T? |
Copied as-is |
T |
T |
Copied as-is |
Nullable intermediate paths on the source side are guarded with if (... is not null).
Nullable intermediate paths on the destination side are auto-instantiated with ??= new.
A source parameter (or the destination parameter of a void mapper) declared nullable, as in Map(Src? source), is checked before anything is mapped. When it is null nothing is mapped: a return-type mapper returns default, and a void mapper returns without touching the destination.
Same-type and implicitly convertible assignments are generated without a converter.
When explicit conversion is needed, DefaultValueConverter is used.
// string -> int
destination.IntValue = DefaultValueConverter.ConvertToInt32(source.StringValue);
// int -> string
destination.StringValue = DefaultValueConverter.ConvertToString(source.IntValue);// int? -> string
destination.StringValue = source.NullableValue is not null
? DefaultValueConverter.ConvertToString(source.NullableValue.GetValueOrDefault())
: default!;public static class CustomConverter
{
public static string ConvertToString(int source) => $"ID_{source}";
public static TDestination Convert<TSource, TDestination>(TSource source) { ... }
}
[Mapper]
[ValueConverter(typeof(CustomConverter))]
public static partial void Map(Source source, Destination destination);The converter class may be nested or generic (typeof(Outer.CustomConverter), typeof(CustomConverter<TMarker>)). Method changes the name its methods are looked up by (default "Convert"): the specialized methods become {Method}To{TargetType}, and the generic fallback {Method}<TSource, TDestination>:
public static class MapConverter
{
public static string MapToString(int source) => $"ID_{source}";
public static TDestination Map<TSource, TDestination>(TSource source) { ... }
}
[Mapper]
[ValueConverter(typeof(MapConverter), Method = "Map")]
public static partial void Map(Source source, Destination destination);
// Generated: destination.Value = MapConverter.MapToString(source.Value);A converter method that is missing, such as the generic fallback of a conversion no specialized method covers, is reported (SMP0104).
Priority order (highest to lowest):
| Level | Scope |
|---|---|
[MapProperty(Converter = nameof(...))] |
Single property |
[ValueConverter] on mapper method |
All properties of that method |
[ValueConverter] on class |
All mapper methods in the class |
DefaultValueConverter |
Fallback |
public static class CustomCollectionConverter
{
public static List<TDest>? ToList<TSource, TDest>(
IEnumerable<TSource>? source, Func<TSource, TDest> mapper) { ... }
public static TDest[]? ToArray<TSource, TDest>(
IEnumerable<TSource>? source, Func<TSource, TDest> mapper) { ... }
}
[Mapper]
[CollectionConverter(typeof(CustomCollectionConverter))]
[MapCollection(nameof(Destination.Items), nameof(Source.Items), Mapper = nameof(MapItem))]
public static partial void Map(Source source, Destination destination);The method is picked by the target type (ToList, ToArray, ToHashSet, ToImmutableArray, ...) unless Converter of [MapCollection] names one, and is called as Method<TSourceElement, TTargetElement>(source, mapper). A method that is missing, does not take the source collection, or returns something the target property cannot take is reported (SMP0104).
[MapperProfile(Culture = "ja-JP")]
internal static partial class AppMappers
{
[Mapper(Culture = "de-DE", NumberFormat = "N2")]
public static partial Dest Map(Src src);
[Mapper]
[MapProperty(nameof(Dest2.Amount), nameof(Src2.Price), Culture = "en-US", NumberFormat = "C")]
public static partial Dest2 Map(Src2 src);
}Priority: [MapProperty] > [Mapper] > [MapperProfile] > CultureInfo.InvariantCulture
With a culture, the specialized methods of the converter are called through their overload taking the culture and the format, as in DefaultValueConverter.ConvertToString(int source, IFormatProvider culture, string? format). A custom [ValueConverter] has to provide that overload for each specialized method it uses (SMP0104 otherwise).
The resolved CultureInfo is cached as a static readonly field in the generated class to avoid repeated GetCultureInfo(...) calls.
Specifying
DateTimeFormat/NumberFormatwithoutCultureis a compile-time error (SMP0401).
Smart.Mapper is fully compatible with NativeAOT and IL trimming.
<IsAotCompatible>true</IsAotCompatible>is declared inSmart.Mapper.csproj- All type conversions are handled through specialized methods - no generic reflection fallback at runtime
- The generated code never uses
Activator.CreateInstance; object creation is expanded inline by the generator (theActionoverloads ofDefaultCollectionConverter, which create elements withnew(), are markedRequiresDynamicCode) [DynamicallyAccessedMembers]annotations are applied toValueConverterAttribute.ConverterTypeandCollectionConverterAttribute.ConverterType
[MapExpression]warning - If an expression contains reflection APIs (Activator,Type.GetType,MethodInfo, etc.), SMP0403 is emitted. Prefer[MapFrom]or[MapUsing]in AOT contexts.
| ID | Description | Severity |
|---|---|---|
| SMP0001 | Mapper method must be static partial |
Error |
| SMP0002 | Mapper method has an invalid number of parameters | Error |
| SMP0003 | Two custom parameters have the same type | Error |
| SMP0004 | Mapper parameter name starts with __ (reserved for the generated code) |
Error |
| SMP0005 | Parameter has a modifier the generated code cannot work with (out, or in / ref readonly on the struct destination of a void mapper) |
Error |
| SMP0101 | Several mapping attributes target the same destination property | Error |
| SMP0102 | BeforeMap method signature does not match |
Error |
| SMP0103 | AfterMap method signature does not match |
Error |
| SMP0104 | Converter method is not found or its signature does not match | Error |
| SMP0105 | Converter return type does not match the target property type | Error |
| SMP0106 | Property condition method signature does not match | Error |
| SMP0201 | MapUsing method signature does not match |
Error |
| SMP0202 | MapUsing return type does not match the target property type |
Error |
| SMP0203 | [MapFrom] target property is not found on the destination type |
Error |
| SMP0204 | MapFrom member is not a parameterless method or a property path of the source type |
Error |
| SMP0205 | MapFrom member type does not match the target property type |
Error |
| SMP0206 | [MapCollection] / [MapNested] source property is not found |
Error |
| SMP0207 | [MapCollection] / [MapNested] target property is not found |
Error |
| SMP0208 | [MapCollection] source property is not a collection type |
Error |
| SMP0209 | [MapCollection] target property is not a collection type |
Error |
| SMP0210 | MapCollection element mapper method is not found or its signature does not match |
Error |
| SMP0211 | MapNested mapper method is not found or its signature does not match |
Error |
| SMP0212 | [MapCollection] / [MapNested] target cannot be assigned (no setter the mapper can call, init-only or required) |
Error |
| SMP0213 | [MapProperty] source property is not found |
Error |
| SMP0214 | [MapProperty] target property is not found or cannot be assigned |
Error |
| SMP0215 | [MapCondition] / NullBehavior.Skip on a target assigned through a constructor or object initializer |
Error |
| SMP0216 | [MapIgnore] on a member assigned through a constructor |
Error |
| SMP0217 | [MapCollection] target cannot take the collection the generated code creates for it |
Error |
| SMP0301 | Constructor parameter has no matching source property | Error |
| SMP0302 | A void mapper cannot assign init-only members (such as the properties of a positional record) |
Error |
| SMP0303 | required member is not mapped |
Error |
| SMP0401 | DateTimeFormat / NumberFormat is specified without Culture |
Error |
| SMP0402 | Not AOT-safe: the conversion may fall back to the generic Convert<TSource, TDestination> |
Error |
| SMP0403 | AOT warning: MapExpression may contain a reflection pattern |
Warning |
| SMP0501 | Strict mode: a destination property is not mapped | Warning |
See Diagnostics.md for the cause of each diagnostic and how to fix it.
Measured with BenchmarkDotNet on .NET 10.
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8524/25H2/2025Update/HudsonValley2)
AMD Ryzen 9 5900X 3.70GHz, 1 CPU, 24 logical and 12 physical cores
.NET SDK 10.0.300
[Host] / MediumRun : .NET 10.0.8 (10.0.8, 10.0.826.23019), X64 RyuJIT x86-64-v3
Job=MediumRun IterationCount=15 LaunchCount=2 WarmupCount=10
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| Direct | 9.391 ns | 0.478 ns | 0.715 ns | 1.01 | 64 B |
| SmartMapper | 9.171 ns | 0.361 ns | 0.529 ns | 0.98 | 64 B |
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| Direct | 93.17 ns | 4.364 ns | 6.531 ns | 1.00 | 128 B |
| SmartMapper | 88.73 ns | 3.195 ns | 4.782 ns | 0.96 | 128 B |
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| Direct | 11.08 ns | 0.390 ns | 0.584 ns | 1.00 | 72 B |
| SmartMapper | 13.68 ns | 1.184 ns | 1.772 ns | 1.24 | 72 B |
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| Direct | 9.038 ns | 0.154 ns | 0.231 ns | 1.00 | 72 B |
| LegacyLambda | 9.341 ns | 0.283 ns | 0.424 ns | 1.03 | 72 B |
| SmartMapper | 9.160 ns | 0.395 ns | 0.591 ns | 1.01 | 72 B |
Caller manages list; SmartMapper is used only for per-element mapping.
| Method | ItemCount | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|---|
| Direct | 10 | 101.1 ns | 2.02 ns | 3.98 ns | 1.00 | 456 B |
| SmartMapper | 10 | 107.6 ns | 2.22 ns | 6.40 ns | 1.07 | 456 B |
| Direct | 100 | 812.7 ns | 29.30 ns | 86.40 ns | 1.01 | 4,056 B |
| SmartMapper | 100 | 745.7 ns | 26.04 ns | 75.96 ns | 0.93 | 4,056 B |
Both Direct and SmartMapper create CollectionWrapper { Items = List<T> }.
| Method | ItemCount | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|---|
| Direct | 10 | 114.6 ns | 2.38 ns | 7.02 ns | 1.00 | 512 B |
| SmartMapper | 10 | 112.2 ns | 3.18 ns | 9.39 ns | 0.98 | 512 B |
| Direct | 100 | 891.4 ns | 25.95 ns | 76.51 ns | 1.01 | 4,112 B |
| SmartMapper | 100 | 916.5 ns | 27.90 ns | 82.27 ns | 1.04 | 4,112 B |
JIT analysis:
- Simple / Conversion: Disassembly confirms JIT generates identical or equivalent instructions. SmartMapper's conversion is faster because the specialized
ConvertToString(InvariantCulture)path avoids boxing.- Nested (1.24x): Disassembly shows both Direct and SmartMapper compile to equivalent code (155 vs 157 bytes) after full inlining of
MapNested+MapAddress. The reported ratio has high variance (StdDev 1.77 ns vs 0.58 ns for Direct, P90 = 15.84 ns vs 11.75 ns), pointing to loop-back branch prediction noise rather than a code quality difference.- Void nested: The lambda-free multi-statement pattern (LegacyLambda 1.03x β SmartMapper 1.01x) confirms elimination of the closure allocation overhead.
- Collection: Both scenarios (item-level and wrapper-level) show SmartMapper within statistical noise of Direct (ratio 0.93β1.07). Allocation is identical in each scenario. The element mapper (
MapItem) is fully inlined by JIT.
Uses xUnit v3 with Microsoft Testing Platform.
# Run all tests
dotnet run --project Smart.Mapper.Tests/Smart.Mapper.Tests.csproj
# Run with code coverage (Cobertura XML under TestResults in the output directory)
dotnet run --project Smart.Mapper.Tests/Smart.Mapper.Tests.csproj -- --coverage --coverage-settings CodeCoverage.runsettingsYou can also run tests from Visual Studio Test Explorer.
Note:
dotnet testis not supported on .NET 10 SDK due to a Microsoft Testing Platform / VSTest incompatibility. Usedotnet run --projectinstead.
Verifies that the Roslyn source generator produces correct output and emits the correct diagnostics.
dotnet run --project Smart.Mapper.Generator.Tests/Smart.Mapper.Generator.Tests.csprojVerifies that the generated mapper code works correctly under NativeAOT publish.
1. Publish as NativeAOT
dotnet publish Smart.Mapper.AotTests/Smart.Mapper.AotTests.csproj -c Release -r win-x64Supported RIDs:
win-x64,linux-x64, etc. Adjust to match your platform.
2. Run the published executable
.\Smart.Mapper.AotTests\bin\Release\net10.0\win-x64\publish\Smart.Mapper.AotTests.exe3. Verify the output
All 8 scenarios must pass:
Smart.Mapper AOT smoke tests starting...
[OK] Basic void mapping
[OK] Basic return mapping
[OK] Type conversion
[OK] Enum mapping
[OK] Null handling
[OK] Nested property mapping
[OK] Collection mapping
[OK] Custom value converter
All AOT smoke tests passed.
If any test fails, the process exits with a non-zero exit code and prints FAIL: <message> to standard error.
4. Check for AOT warnings (optional)
dotnet publish Smart.Mapper.AotTests/Smart.Mapper.AotTests.csproj -c Release -r win-x64 2>&1 |
Select-String "IL2|IL3"No IL2xxx / IL3xxx diagnostics should appear.
Future improvements under consideration:
[MapCollection]/[MapNested]targeting init-only or required members β currently rejected withSMP0212because the generated loop runs after construction. Could be supported by hoisting the built collection / nested instance into a local before construction and assigning it in the object initializer.- Direct
FrozenSetconstruction β the generated code builds aHashSet<T>and callsToFrozenSet(two-phase by BCL design). If the BCL ever ships a frozen-collection builder API, the intermediate set can be eliminated. - Generic fallback
Convert<TSource, TDestination>forHalf/Int128/UInt128/BigIntegersources β these currently reach the boxing fallback when routed through the generic converter opt-in; specialized branches can be added if demand arises (the default specialized-method path already covers them). - Generator incrementality tuning β output is regenerated per run via
Collect()and destination/source property walks are repeated per feature pass. Measured cost is negligible today; revisit (per-class output splitting, property-list caching) if very large models appear.
MIT