Use Mammoth.LiteMapper to declare compile-time mappings, configure conversions, map collections, and update existing objects.
Runnable examples:
samples/Mammoth.LiteMapper.Samples.Basic/Program.cssamples/Mammoth.LiteMapper.Samples.Collections/Program.cssamples/Mammoth.LiteMapper.Samples.AspNetCore/Program.cs
For coding-agent assistance, use the repository's mammoth-litemapper skill. See agent skill installation for the Skills CLI command. Installing the skill does not install the NuGet package in your application.
Normal consumers reference the primary package only:
<PackageReference Include="Mammoth.LiteMapper" />Mammoth.LiteMapper.Abstractions is pulled in as a dependency and contains the public attributes, enums, and LiteMapperCycleException. The generator is included as an analyzer asset and is not a runtime dependency.
A generated mapper is a partial method inside a class marked with [LiteMapper].
From samples/Mammoth.LiteMapper.Samples.Basic/Program.cs:
[LiteMapper]
public static partial class StaticMapper
{
public static partial StaticTarget Map(StaticSource source);
}Static and instance mapper classes are supported:
[LiteMapper]
public sealed partial class InstanceMapper
{
public partial InstanceTarget Map(InstanceSource source);
}Supported generated method shapes include new-object mappings, extension-method mappings on static mapper classes, and existing-target update mappings. Generated methods must be bodyless partial methods. Handwritten methods can live beside generated methods.
Mapper classes can be top-level or nested; nested mapper classes require every containing type to be partial. Generic mapper classes and generic containing types are not generated in 1.0.
A containing type can also be a partial record, record struct, or interface. For example, public partial record Container { [LiteMapper] public static partial class Mapper { public static partial Target Map(Source source); } } keeps the mapper inside Container; the mapper itself remains a class.
Instance mappers may use fields, properties, constructor-injected dependencies, and instance converter methods. Static mapper classes may use static converters only.
Ordinary inherited handwritten methods may participate when normal C# accessibility and resolution permit them, but mapper configuration is not inherited implicitly.
Select an accessible base-class converter explicitly:
public class MapperBase
{
protected int Parse(string value) => int.Parse(value) + 1;
}
public class ParsedSource { public string Value { get; set; } = "12"; }
public class ParsedTarget { public int Value { get; set; } }
[LiteMapper]
public partial class ParsedMapper : MapperBase
{
[MapProperty(Source = "Value", Target = "Value", Use = nameof(Parse))]
public partial ParsedTarget Map(ParsedSource source);
}new ParsedMapper().Map(new ParsedSource()).Value is 13. An instance converter requires an instance mapping method; a private base method is inaccessible.
For a simple new-object mapping:
public sealed class Source
{
public int Age { get; set; }
public string? Name { get; set; }
}
public sealed class Target
{
public int Age { get; set; }
public string? Name { get; set; }
}
[LiteMapper]
public static partial class Mapper
{
public static partial Target ToTarget(Source source);
}LiteMapper generates direct C# similar to:
// <auto-generated/>
#nullable enable
public static partial class Mapper
{
public static partial Target ToTarget(Source source)
{
var target = new Target()
{
Age = source.Age,
Name = source.Name
};
return target;
}
}No runtime mapper registry, reflection, or dynamic dispatch is involved. By default, a non-nullable root source parameter does not add a runtime null guard. If GuardNonNullSource is enabled, the generated method starts with:
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}Public types live in the Mammoth.LiteMapper namespace:
LiteMapperAttribute: marks mapper classes and holds mapper-level options.MappingOptionsAttribute: overrides supported options on one mapping method.LiteMapperDefaultsAttribute: assembly-level defaults for stable cross-cutting options.UseMapperAttribute: registers external static mapper or converter-container types.DefaultMappingAttribute: selects a default for a source/destination pair within the applicable local or registered external mapping stage.MappingConverterAttribute: marks a converter method.MappingConstructorAttribute: marks the constructor LiteMapper should select.MapPropertyAttribute: configures source path, target member, and converter selection.IgnoreTargetAttribute,IgnoreSourceAttribute,UseTargetDefaultAttribute: member-level mapping controls.LiteMapperCycleException: thrown by generated cycle tracking.- Enums:
NameMatching,UnmappedMemberPolicy,NullableMismatchPolicy,NullCollectionStrategy,NumericConversion,EnumMappingStrategy,EnumNumericConversion,UnmatchedEnumValuePolicy,ReferenceHandling, andOptionState.
Configuration enum values are checked by their constant value. For example, NameMatching = (NameMatching)3 is equivalent to NameMatching.IgnoreCase and maps source value to target Value; named enum members are usually clearer. Undefined values such as (NameMatching)999 are rejected. Invalid assembly defaults prevent mapper generation for that compilation.
Configuration resolves from the most specific scope to the least specific scope:
[MappingOptions]on the mapping method.[LiteMapper]on the mapper class.[assembly: LiteMapperDefaults(...)]for assembly-level defaults.- Library defaults.
Assembly-level defaults and external mapper registrations are compile-time configuration inputs. When either changes during an incremental build, LiteMapper reevaluates affected generated mappings and diagnostics; unrelated output can remain cached where Roslyn preserves the relevant candidate identity. This affects build invalidation only and does not create a runtime mapper registry.
[assembly: LiteMapperDefaults(
NameMatching = NameMatching.ExactThenIgnoreCase,
UnmappedTargetMembers = UnmappedMemberPolicy.Warning,
NullableMismatch = NullableMismatchPolicy.Error)]
[LiteMapper(AllowExplicitOperators = true)]
public static partial class CustomerMapper
{
[MappingOptions(
UnmappedTargetMembers = UnmappedMemberPolicy.Error,
AllowExplicitOperators = OptionState.Enabled,
GuardNonNullSource = OptionState.Enabled,
IgnoreNullSourceMembers = OptionState.Disabled)]
public static partial CustomerDto Map(Customer source);
}Library defaults are exact name matching followed by a unique case-insensitive match, ordinary unmapped target members reported as errors, unmapped source members ignored, nullable mismatches reported as errors, null collections reported as errors except where nullable target collections can preserve null, implicit numeric conversion only, explicit operators disabled, enum mapping by name, unmatched enum values reported as errors, no cycle tracker, non-null root source guards disabled, and patch null skipping disabled. Explicit UnmappedTargetMembers = UnmappedMemberPolicy.Ignore, Info, Warning, or Error overrides this target default. IgnoreTarget and UseTargetDefault are explicit member-level opt-outs; required and otherwise mandatory target members remain errors as specified in section 6.4.
Accessible public inherited source members participate, including properties inherited by interfaces:
public interface IBase { int Value { get; } }
public interface ISource : IBase { }
public class Data : ISource { public int Value => 3; }
public class InheritedTarget { public int Value { get; set; } }
[LiteMapper]
public static partial class InheritedMapper
{
public static partial InheritedTarget Map(ISource source);
}InheritedMapper.Map(new Data()).Value is 3. Inherited members also work in configured paths such as Data.Value and in ignore/default member names. A shared property reached through diamond interface inheritance remains one member. Unrelated inherited properties with the same name are ambiguous (LITEMAPPER1004); a more-derived declaration hides its base member with warning LITEMAPPER1005.
By default, LiteMapper maps readable source members to writable or constructible target members by name. An ordinary target member that remains unmapped is an error by default. Configure name matching and unmapped-member handling on the mapper or on a method:
[LiteMapper(
NameMatching = NameMatching.ExactThenIgnoreCase,
UnmappedTargetMembers = UnmappedMemberPolicy.Error,
UnmappedSourceMembers = UnmappedMemberPolicy.Warning)]
public static partial class CustomerMapper
{
public static partial CustomerDto Map(Customer source);
}Strict source-member completeness is an opt-in boundary-mapping guardrail. Set UnmappedSourceMembers = UnmappedMemberPolicy.Error on a mapper, on one method with [MappingOptions], or in [assembly: LiteMapperDefaults(...)] to report every readable source member that is not used by that mapping. Each report is the configurable LITEMAPPER1003 diagnostic and names the exact source member. Use Warning or Info to stage adoption, or Ignore to disable the check for a scope. [IgnoreSource(nameof(Source.LegacyCode))] records an intentional exception when a source member is known not to belong in the boundary.
[LiteMapper(UnmappedSourceMembers = UnmappedMemberPolicy.Error)]
public static partial class BoundaryMapper
{
public static partial CustomerDto Map(Customer source);
}The same option is available on [MappingOptions] for a method-specific boundary and on [LiteMapperDefaults] for an assembly-wide default; normal method, mapper, assembly, and library precedence applies. The default remains Ignore because source models commonly contain metadata or fields that are intentionally absent from a particular DTO. Strict source checking does not relax target completeness or mandatory target diagnostics.
Useful options:
NameMatching.Exact: ordinal exact member names only.NameMatching.ExactThenIgnoreCase: exact first, then unique ignore-case match.NameMatching.IgnoreCase: unique ignore-case match.UnmappedMemberPolicy.Ignore,Info,Warning,Error: controls diagnostics for unmapped source or target members.
Member candidates are public instance properties and fields. Hidden members are resolved to the most-derived usable member, with property-over-field preference when needed.
Source paths in [MapProperty] may use dotted member paths such as "Email.Value". Source paths must resolve to members and cannot use method-call syntax. Source methods are not discovered automatically; use a converter or root-source MapProperty.Use method when a method-derived value is needed.
Nullable source paths are evaluated once per segment. When an explicit converter is selected, its parameter annotation controls traversal nulls. For example:
[MapProperty(Source = "Address.Code", Target = nameof(Target.Code), Use = nameof(Normalize))]
public static partial Target Map(Source source);
private static string? Normalize(string? value) => value ?? "missing";If Source.Address is null, Normalize(null) is invoked and can return "missing". If Normalize instead accepts string, NullableMismatchPolicy.Error reports LITEMAPPER2001; Throw checks the full Address.Code path before invoking the converter, even when Target.Code is nullable.
Target paths are direct members only. Dotted target paths are not supported. Duplicate configuration for the same target or ambiguous configuration reports diagnostics.
For explicit source paths and renames, the generated object initializer uses the configured member path directly:
var target = new Target()
{
Country = source.Address.Country.Code,
Id = source.Identifier
};
return target;New-object mappings return a destination:
public static partial CustomerDto Map(Customer source);Static mapper classes can declare extension methods:
public static partial CustomerDto ToDto(this Customer source);Existing-target mappings update a destination by parameter:
public static partial void Update(Customer source, CustomerDto destination);
public static partial CustomerDto UpdateAndReturn(Customer source, CustomerDto destination);
public static partial void UpdateCounter(Counter source, ref CounterDto destination);Unsupported generated method forms include async methods, generic mapping methods, generic mapper classes, generic containing types, ambiguous bodyless partial signatures, unsupported accessibility, and open generic mapping declarations.
LiteMapper can construct classes, structs, records, and record structs using accessible constructors and object initializers. It supports init and required members when the target construction path satisfies them.
Read-only fields and properties can receive mapped values through a selected constructor. For example, Target(int value) { Value = value; } can initialize public readonly int Value from source Value = 3. An ordinary unbound read-only value field remains unchanged and follows UnmappedTargetMembers; it is never assigned by an object initializer. Required and non-nullable target obligations still apply.
Use [MappingConstructor] when one constructor should be selected explicitly:
public sealed class CustomerDto
{
[MappingConstructor]
public CustomerDto(int id, string name)
{
Id = id;
Name = name;
}
public int Id { get; }
public string Name { get; }
}Constructor arguments also use explicit configuration and converters:
[LiteMapper]
public static partial class Mapper
{
[MapProperty(Source = nameof(Source.Raw), Target = nameof(Target.Value), Use = nameof(Parse))]
public static partial Target Map(Source source);
private static int Parse(string value) => int.Parse(value);
}
public class Source { public string Raw { get; set; } = string.Empty; }
public class Target
{
public Target(int value) { Value = value; }
public int Value { get; }
}Mapping new Source { Raw = "12" } constructs Target(12). Nullability follows the constructor parameter annotation. Recursive constructor arguments participate in cycle detection.
Use [UseTargetDefault] to preserve a real default:
[LiteMapper]
public static partial class CustomerMapper
{
[UseTargetDefault(nameof(CustomerDto.DisplayName))]
public static partial CustomerDto Map(Customer source);
}For a constructor Target(int id = 17) that initializes Id, [UseTargetDefault(nameof(Target.Id))] omits the argument even if the source contains Id = 99; the result keeps 17. Without the attribute, ordinary matching passes 99. An initializer alone does not satisfy C# required: the selected constructor must carry SetsRequiredMembers, or the generated initializer must satisfy the member. Constructor-bound members are not assigned twice.
Use [MapProperty] for renames, dotted source paths, and explicit conversion methods:
[LiteMapper]
public static partial class CustomerMapper
{
[MapProperty(Source = "Email.Value", Target = nameof(CustomerDto.Email))]
[MapProperty(Target = nameof(CustomerDto.DisplayName), Use = nameof(MapDisplayName))]
public static partial CustomerDto Map(Customer source);
private static string MapDisplayName(Customer source) =>
source.FirstName + " " + source.LastName;
}Use [IgnoreTarget] and [IgnoreSource] to suppress specific members while still validating the configured names:
Here DisplayName is nullable (public string? DisplayName { get; set; }). Ignoring a target does not bypass required/non-nullable member obligations. A constructor may satisfy them; otherwise map the member or approve a real default where C# permits it. Combining MapProperty with IgnoreTarget or UseTargetDefault for the same member reports LITEMAPPER1008, regardless of attribute order.
[LiteMapper]
public static partial class CustomerMapper
{
[IgnoreTarget(nameof(CustomerDto.DisplayName))]
[IgnoreSource(nameof(Customer.LegacyCode))]
public static partial CustomerDto Map(Customer source);
}The generated code for the configured mapping is direct assignment code. For example, [MapProperty(Source = "Email.Value", Target = nameof(CustomerDto.Email))] emits an assignment shaped like:
Email = source.Email.ValueUse [MappingConverter], Map{TargetMember}, or explicit MapProperty.Use methods for custom values and conversions:
Eligible generated partial mapping declarations participate automatically. Handwritten local methods require explicit MapProperty.Use, [MappingConverter], [DefaultMapping], or the documented Map{TargetMember} convention. Other unmarked local methods remain ordinary helpers, even if their signatures match; naming a helper ToDto alone does not register it. Explicit [UseMapper] registration opts in compatible methods in that external static container. Selected methods must still satisfy signature, accessibility, nullability, and precedence rules.
[LiteMapper]
public static partial class CustomerMapper
{
public static partial CustomerDto Map(Customer source);
private static string MapDisplayName(Customer source) =>
source.FirstName + " " + source.LastName;
[MappingConverter]
private static EmailDto ConvertEmail(EmailAddress source) =>
new EmailDto(source.Value);
}Use [DefaultMapping] to select among eligible mappings for a source/destination pair within the local mapping stage or registered external mapping stage:
[DefaultMapping]
public static CustomerDto ToCustomerDto(Customer source) =>
new CustomerDto { Id = source.Id };Use [UseMapper] on a mapper class or assembly to register an external static mapper or converter container:
The container must be static and may be a non-generic or closed constructed generic type. It must expose an accessible synchronous mapping or converter with a supported signature. For example, typeof(External<int>) is valid when the closed container exposes public static int Parse(string value), even when another mapping does not need it; public static void Ping() alone is not. Unbound registrations such as typeof(External<>) and generic methods are unsupported. Supported two-parameter update methods also make a container usable. A missing type or a container without usable methods reports LITEMAPPER0010; an existing non-static container reports LITEMAPPER0011. Accessibility follows C#: a private converter can be used by a mapper nested in its declaring container.
A handwritten ref or ref readonly converter result can supply a normal value assignment. For example, copying a returned ref int containing 3 into an int target keeps the target at 3 when the source later changes to 7.
[UseMapper(typeof(SharedConverters))]
[LiteMapper]
public static partial class CustomerMapper
{
public static partial CustomerDto Map(Customer source);
}Local mapping methods precede registered external converters. A default resolves competing mappings within its stage; it does not override earlier stages. Explicit MapProperty.Use, local [MappingConverter] methods, and Map{TargetMember} methods all precede local mappings.
For example, the local default adds 10 while the registered external converter adds 20:
using Mammoth.LiteMapper;
public class SourceValue { public int Value { get; set; } }
public class TargetValue { public int Value { get; set; } }
public class SourceEnvelope { public SourceValue Item { get; set; } = new(); }
public class TargetEnvelope { public TargetValue Item { get; set; } = new(); }
[LiteMapper]
[UseMapper(typeof(ExternalConverters))]
public static partial class EnvelopeMapper
{
public static partial TargetEnvelope Map(SourceEnvelope source);
[DefaultMapping]
private static TargetValue MapDefault(SourceValue source) =>
new() { Value = source.Value + 10 };
}
public static class ExternalConverters
{
[MappingConverter]
public static TargetValue Convert(SourceValue source) =>
new() { Value = source.Value + 20 };
}Calling EnvelopeMapper.Map(new SourceEnvelope { Item = new SourceValue { Value = 3 } }) returns Item.Value == 13. To select the external converter explicitly and obtain 23, add [MapProperty(Source = nameof(SourceEnvelope.Item), Target = nameof(TargetEnvelope.Item), Use = nameof(ExternalConverters.Convert), ConverterType = typeof(ExternalConverters))] to Map.
Only one visible mapping for a source/destination pair may carry [DefaultMapping]. For example, adding this second local default alongside MapDefault reports LITEMAPPER3002 when the pair is requested:
[DefaultMapping]
private static TargetValue AnotherDefault(SourceValue source) =>
new() { Value = source.Value + 20 };The same restriction applies when the competing default is in a registered external mapper. Keep one default for the pair, or select an eligible method explicitly with MapProperty.Use without duplicate default attributes.
For post-processing, wrap a generated core mapping in a handwritten method:
In this example the non-nullable DisplayName has a declared string.Empty initializer, explicitly approved for the generated core mapping.
[LiteMapper]
public static partial class CustomerMapper
{
[UseTargetDefault(nameof(CustomerDto.DisplayName))]
private static partial CustomerDto MapCore(Customer source);
public static CustomerDto Map(Customer source)
{
var dto = MapCore(source);
dto.DisplayName = source.FirstName + " " + source.LastName;
return dto;
}
}This is the supported post-processing wrapper pattern. No after-map hook is provided in 1.0.
LiteMapper uses identity and implicit C# conversions automatically. Explicit operators are disabled by default and must be enabled deliberately:
[LiteMapper(
AllowExplicitOperators = true,
NumericConversion = NumericConversion.Checked)]
public static partial class InvoiceMapper
{
public static partial InvoiceDto Map(Invoice source);
}NumericConversion.ImplicitOnly rejects narrowing numeric conversions. NumericConversion.Checked emits checked conversions. NumericConversion.Unchecked emits unchecked conversions. String parsing, formatting, Parse, TryParse, ToString, culture-dependent conversion, date/string conversion, and Guid/string conversion are not automatic; use a converter.
Numeric and explicit-operator options also apply to a selected converter's result:
public class TextNumber { public string Value { get; set; } = "12"; }
public class IntNumber { public int Value { get; set; } }
[LiteMapper(NumericConversion = NumericConversion.Checked)]
public static partial class NumberMapper
{
[MapProperty(Source = "Value", Target = "Value", Use = nameof(ReadNumber))]
public static partial IntNumber Map(TextNumber source);
private static long ReadNumber(string value) => long.Parse(value);
}NumberMapper.Map(new TextNumber()).Value is 12. Input "2147483648" throws OverflowException; changing the policy to Unchecked yields int.MinValue. The converter runs once per mapped value, and its own exceptions propagate unchanged. Nullable results follow the target nullability before narrowing: long? to int requires the applicable null policy, while long? to int? preserves null. A method-level option overrides the mapper-level option.
For example:
[LiteMapper(NumericConversion = NumericConversion.Checked)]
public static partial class Mapper
{
public static partial byte Checked(int source);
}
[LiteMapper(NumericConversion = NumericConversion.Checked)]
public static partial class UncheckedMapper
{
[MappingOptions(NumericConversion = NumericConversion.Unchecked)]
public static partial byte Map(int source);
}Mapper.Checked(255) returns 255; Mapper.Checked(256) throws OverflowException. The method override makes UncheckedMapper.Map(256) return 0. Numeric policies also apply to members, collection elements, and dictionary keys/values. Disabled narrowing reports LITEMAPPER2007, disabled explicit operators report LITEMAPPER2006, and ambiguous language conversions report LITEMAPPER2005.
For nullable numeric inputs such as int? to byte, NullableMismatchPolicy.Error reports LITEMAPPER2001 at a root/member or LITEMAPPER2003 for an element. With Throw, null is checked before conversion: root null throws ArgumentNullException; a null member/element throws InvalidOperationException identifying its path. The same checks apply when unwrapping bool?, Guid?, DateTime?, enum, and custom-struct nullable values to their underlying types.
Nullable boxing follows the same policy as other nullable conversions. For example, object Map(int? source) reports LITEMAPPER2001 under Error; under Throw, 3 becomes a boxed int and null throws ArgumentNullException. Mapping int?[] to object[] reports LITEMAPPER2003 under Error and rejects a null element under Throw. Use object? or object?[] to allow null. Reference annotations that are oblivious under #nullable disable do not introduce mismatch diagnostics.
Null behavior is configured with NullableMismatchPolicy and NullCollectionStrategy:
[LiteMapper(
NullableMismatch = NullableMismatchPolicy.Throw,
NullCollections = NullCollectionStrategy.Empty)]
public static partial class CustomerMapper
{
public static partial CustomerDto Map(Customer source);
}NullableMismatchPolicy.Error reports compile-time diagnostics for nullable-to-non-null mappings. NullableMismatchPolicy.Throw emits runtime checks for supported paths. NullCollectionStrategy.Empty maps null collections to empty target collections; Preserve preserves null when legal; Error reports unsupported null collection flows. These collection rules include null introduced by an intermediate configured path segment. For example, if Source.Data is nullable, mapping Data.Items to a non-null list is rejected by the contextual default and by Preserve; Empty creates an empty list.
A converter returning a nullable value also follows the target nullability. For example:
public class ConverterSource { public string Value { get; set; } = string.Empty; }
public class ConverterTarget { public long Value { get; set; } }
[LiteMapper(NullableMismatch = NullableMismatchPolicy.Throw)]
public static partial class ValueMapper
{
[MapProperty(Source = nameof(ConverterSource.Value), Target = nameof(ConverterTarget.Value), Use = nameof(ReadValue))]
public static partial ConverterTarget Map(ConverterSource source);
private static int? ReadValue(string value) => value == "missing" ? null : 12;
}Mapping new ConverterSource { Value = "present" } produces Value == 12L. Mapping Value = "missing" invokes ReadValue once and throws InvalidOperationException identifying Value. With NullableMismatchPolicy.Error, the nullable converter result reports LITEMAPPER2010. Changing the target property to long? permits the result to remain null instead.
Nullable element types are preserved in arrays, lists, sets, and dictionary values. For patch updates, a bool? source value of null leaves an existing true target unchanged when IgnoreNullSourceMembers is enabled; a subsequent non-null false maps normally, for both bool and bool? targets.
Root source null behavior follows the declared source and return nullability. A non-nullable source parameter rejects nullable input at compile time when visible to analysis. By default, LiteMapper does not emit a runtime guard only because a root source parameter is non-nullable. Enable GuardNonNullSource on the mapper or mapping method to emit an ArgumentNullException guard for that root source parameter.
When NullableMismatchPolicy.Throw requires runtime validation, generated code throws at the failing source path:
if (source.Address.Country.Code == null)
{
throw new InvalidOperationException(
"Source member path 'Address.Country.Code' was null.");
}Nested object mappings are generated as private closed-type helper methods when LiteMapper can construct the nested target type:
public sealed class OrderSource
{
public CustomerSource Customer { get; set; } = new CustomerSource();
}
public sealed class OrderDto
{
public CustomerDto Customer { get; set; } = new CustomerDto();
}Visible handwritten or generated mapping methods for the same nested pair are reused before structural helper generation. Abstract/interface destinations and object runtime dispatch require an explicit converter or handwritten mapping.
Nested helpers are closed over the concrete source/destination type pair. Closed generic model types can be mapped when the containing mapper and mapping method are not generic. Nullable nested objects follow the same nullability policy as other members.
Nested and collection helpers are private implementation details within the generated mapper. Helper names are allocated deterministically per mapper: handwritten members and other generated helper signatures are reserved, identical closed source/destination pairs reuse one name, and distinct pairs are disambiguated even when their preferred shape-plus-hash names collide. Allocation does not depend on syntax-tree order or equivalent compilation order. Consumer code must not reference or depend on helper names or signatures.
For existing-target nested objects, LiteMapper replaces writable nested targets by default. Mutation of an existing nested object requires an explicit compatible existing-target nested mapping method.
For a non-null get-only child, declare its updater alongside the parent update:
public class ChildSource { public int Value { get; set; } }
public class ChildTarget { public int Value { get; set; } }
public class ParentSource { public ChildSource Child { get; set; } = new(); }
public class ParentTarget { public ChildTarget Child { get; } = new(); }
[LiteMapper]
public static partial class ParentMapper
{
public static partial void Apply(ParentSource source, ParentTarget target);
public static partial void ApplyChild(ChildSource source, ChildTarget target);
}With source Child.Value == 3, Apply sets the existing target child's value to 3 and preserves its identity. Without a compatible updater, the get-only child reports LITEMAPPER5005.
For a writable non-null child, select the updater explicitly with [MapProperty(Source = nameof(Source.Child), Target = nameof(Target.Child), Use = nameof(ApplyChild))]. With void ApplyChild(ChildSource source, ChildTarget target), source value 3 updates the original child to 3 and preserves its identity. Without explicit updater selection, writable children use replacement by default.
Omitting Source in [MapProperty(Target = nameof(Target.Child), Use = nameof(ApplyChild))] passes the root source to void ApplyChild(Source source, ChildTarget target). No source member named Child is required: the updater can copy root Value == 3 into target.Child.Value.
Patch mode also applies to nested updater calls. If source.Child is null and IgnoreNullSourceMembers is enabled, the updater is skipped and the existing target child remains unchanged. With the default mismatch policy, passing a nullable child to a non-null updater parameter reports LITEMAPPER2001; Throw raises InvalidOperationException whose message includes the Child path.
A destination-returning updater can create a nullable writable child. For example, ChildTarget ApplyChild(ChildSource source, ChildTarget? target) may return a new child; the parent mapping assigns that return value. A nullable get-only child cannot store such a replacement and reports LITEMAPPER5005.
For registered external child updaters, class-level [UseMapper] registration precedes assembly-level registration. For example, if the class-registered updater adds 20 and the assembly-registered updater adds 10, source value 3 produces 23. Two equally eligible updaters in the same registration scope without a unique default report LITEMAPPER3001.
When explicitly selecting an overloaded nested updater with MapProperty.Use, identity source compatibility wins. For example, for a ChildSource member, Chosen(ChildSource source, ChildTarget target) wins over Chosen(object source, ChildTarget target). If the selected overload adds 10, source value 3 becomes target value 13.
Generated structural nested mappings use private helper methods, for example:
private static CustomerDto MapNested_CustomerSource_To_CustomerDto(CustomerSource source)
{
var target = new CustomerDto()
{
Name = source.Name
};
return target;
}From samples/Mammoth.LiteMapper.Samples.Collections/Program.cs:
[LiteMapper]
public static partial class CollectionMapper
{
public static partial OrderTarget Map(OrderSource source);
}Supported collection mapping includes:
- arrays and jagged arrays;
IEnumerable<T>,IReadOnlyCollection<T>,IReadOnlyList<T>,ICollection<T>,IList<T>,List<T>;- sets including
HashSet<T>and set interfaces; - dictionaries including
Dictionary<TKey, TValue>and dictionary interfaces; - nested collections and collections of nested objects;
- dictionary key and value conversion.
LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information is available. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays.
For array results from sources without a cheap, reliable count, mapping uses an O(n) temporary growing buffer and then creates the final array. It does not count by enumerating first, and each element is converted once. Counted sources allocate their final array directly. The same rule applies to interfaces whose concrete result is an array.
Public collection mappings are allowed when the declared mapping method itself maps one supported collection shape to another supported collection shape:
public static partial List<CustomerDto> MapCustomers(Customer[] source);Interface target defaults follow specification section 15.3:
| Declared target | Concrete result |
|---|---|
IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T> |
T[] |
ICollection<T>, IList<T> |
List<T> |
ISet<T>, IReadOnlySet<T> (when available) |
HashSet<T> |
IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue> |
Dictionary<TKey, TValue> |
These defaults also apply to nested collection members and empty results under NullCollectionStrategy.Empty. Ordering is preserved for sequence mappings. Sets and dictionaries use normal destination semantics. Collection mappings produce a mutable copy; read-only interfaces do not imply immutable objects. Existing-target mappings replace collection members rather than mutating them in place.
For collection copies, generated code is a normal allocation plus one enumeration:
var target = new List<string>(source.Count);
foreach (var item in source)
{
target.Add(item);
}
return target;When element mapping is required, member assignments call generated helpers:
Items = MapCollection_List_Item_To_ItemDto_Array(source.Items)Existing-target mapping updates an existing destination object:
[LiteMapper]
public static partial class CustomerMapper
{
public static partial void Update(Customer source, CustomerDto destination);
public static partial CustomerDto UpdateAndReturn(Customer source, CustomerDto destination);
}Patch-style null skipping is enabled with IgnoreNullSourceMembers:
[LiteMapper(IgnoreNullSourceMembers = true)]
public static partial class CustomerPatchMapper
{
public static partial void Apply(CustomerPatch source, CustomerDto destination);
}Existing-target mappings replace collection members rather than applying partial collection updates. init members are not assigned during updates.
For the non-nullable destination signatures above, null destination arguments throw at runtime. Destination-returning update methods return the same destination instance after mutation. Struct destination updates use ref destination parameters.
A nullable destination parameter permits construction when the caller passes null. The method must return the destination, and its constructor arguments must be mappable:
public class UpdateSource { public int Value { get; set; } }
public class UpdateTarget
{
public UpdateTarget(int value) { Value = value; }
public int Value { get; set; }
}
[LiteMapper]
public static partial class UpdateMapper
{
public static partial UpdateTarget Update(UpdateSource source, UpdateTarget? destination);
}var created = UpdateMapper.Update(new UpdateSource { Value = 12 }, null);
// created.Value is 12, supplied through UpdateTarget(int value).
var updated = UpdateMapper.Update(new UpdateSource { Value = 17 }, created);
// updated.Value is 17; ReferenceEquals(created, updated) is true.When constructing the replacement, constructor-bound members are not assigned a second time. When updating an existing destination, its writable members are assigned normally.
Generated existing-target mappings mutate the supplied destination:
if (target == null)
{
throw new ArgumentNullException(nameof(target));
}
target.Id = source.Id;
target.Name = source.Name;
return target;Existing-target updates are non-transactional and deterministic. Members are evaluated and assigned in target-member name order. If a later getter, converter, nested mapping, collection operation, or setter throws, earlier assignments remain and later members are not attempted. LiteMapper may capture a source value once within a member update when a null guard and assignment both need it; this does not provide rollback. New-object mappings keep their separate construction and initialization behavior.
From samples/Mammoth.LiteMapper.Samples.AspNetCore/Program.cs:
[LiteMapper]
public static partial class UserMapper
{
public static partial UserDto Map(User source);
}The ASP.NET Core sample maps a domain object to a DTO before writing JSON from a minimal API endpoint. No dependency-injection registration extension is required for this static mapper shape.
Enums can be mapped by name or by value:
[LiteMapper(
EnumMapping = EnumMappingStrategy.ByName,
UnmatchedEnumValues = UnmatchedEnumValuePolicy.Throw)]
public static partial class StatusMapper
{
public static partial TargetStatus Map(SourceStatus source);
}EnumMappingStrategy.ByName emits deterministic name-based mapping. EnumMappingStrategy.ByValue uses numeric conversion controlled by EnumNumericConversion.Checked or Unchecked; for example, set EnumNumericConversion = EnumNumericConversion.Checked to reject numeric overflow. [Flags] enum composites are supported for valid atomic flag mappings. Use custom converters when enum semantics are domain-specific.
Nullable enum mappings preserve the configured enum strategy:
Under EnumNumericConversion.Unchecked, a declared value of 300 in a long-backed source enum maps to 44 in a byte-backed target enum. Checked conversion retains overflow validation. The same numeric policy applies to unmatched names using UnmatchedEnumValuePolicy.ByValue.
For flags, a source composite such as Both = Read | Write can map without a target member named Both: if target Read = 4 and Write = 8, the result is 12. A matching target composite must agree with those mapped atoms: Both = 12 is valid, while Both = 16 reports LITEMAPPER7002 and prevents generation of that mapping. Signed high-bit flags are supported; unknown source bits still throw. Ordinary source aliases must each resolve under the selected unmatched-name policy to the same target numeric value. For example, source aliases Known = 1 and Alias = 1 can map to target Known = 9 and Alias = 9.
public enum From { None = 0, Ready = 1 }
public enum To { None = 0, Ready = 9 }
[LiteMapper]
public static partial class NullableStatusMapper
{
public static partial To? Map(From? source);
}
// NullableStatusMapper.Map(From.Ready) == To.Ready (numeric value 9)
// NullableStatusMapper.Map(null) == nullThe same name matching and null preservation apply to members and collection elements. A nullable source targeting a non-null enum follows Error or Throw; unknown numeric values still throw ArgumentOutOfRangeException under by-name mapping. Patch mappings with IgnoreNullSourceMembers preserve the existing enum value when the source is null.
From samples/Mammoth.LiteMapper.Samples.Basic/Program.cs:
[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)]
public static partial class CycleMapper
{
public static partial NodeTarget Map(NodeSource source);
}ReferenceHandling.ThrowOnCycle enables generated cycle tracking for recursive type graphs. A detected cycle throws LiteMapperCycleException. Non-recursive mappings do not allocate cycle-tracker state.
LiteMapperCycleException exposes SourceType, DestinationType, MappingMethod, and MemberPath for the detected active-path cycle. It does not expose SourcePath or DestinationPath properties.
Recursive components may pass through value types and declared mapping methods. For example, an A -> B -> A graph mapped by declared MapA and MapB methods uses one tracker; a cycle reports MapA and member path B.A. A class-to-struct-to-class path forwards the tracker through the struct without tracking or boxing the struct itself.
Interfaces and abstract classes are not automatically constructed as destinations. Use a handwritten mapping or converter that returns a concrete type.
object to a concrete destination requires an explicitly selected converter; LiteMapper does not generate runtime type dispatch. dynamic mapping is not generated. Ref-like types such as Span<T> and ReadOnlySpan<T>, pointer types, and function-pointer types require handwritten code when legal C# allows it.
Tuple-to-tuple mapping uses C# ValueTuple values positionally when arity matches and each element is convertible. Names do not participate:
[LiteMapper]
public static partial class TupleMapper
{
public static partial (long Id, string Label) Map((int Number, string Text) source);
}
var mapped = TupleMapper.Map((Number: 7, Text: "seven"));
// mapped.Id == 7L; mapped.Label == "seven"Automatic tuple-to-object and object-to-tuple structural mapping is unsupported and reports LITEMAPPER2004. Use an explicit converter or handwritten mapping for those boundaries.
Generated mappings use direct C# constructs: constructors, assignments, loops, casts, and ordinary method calls. Supported generated paths use zero runtime reflection, no runtime type scanning, no dynamic dispatch, no runtime code generation, and no runtime mapper registry.
The generator and Roslyn assemblies are build-time dependencies and are not copied to consumer runtime output. Mappings can be used in trimmed and Native AOT applications.
For example, publish a console application using generated mappings with:
dotnet publish -c Release -p:PublishAot=true -p:TreatWarningsAsErrors=trueNative AOT requires the platform's native build tools. On Linux, install Clang and the zlib development package; on Windows, run from an MSVC developer environment with the C++ linker available. A missing linker prevents publishing even when the generated mappings compile successfully.
Shipping assemblies target netstandard2.0. Consumer targets are netstandard2.0, net8.0, net9.0, and net10.0, with a C# 9 minimum language version. Use a compiler host compatible with Roslyn 4.8.0 or later. The generator is delivered as an analyzer asset.
Windows and Linux are supported. A netstandard2.0 library runs inside an application targeting a compatible runtime; for example, a .NET 10 console application can reference and execute its generated mappings.
The primary Mammoth.LiteMapper package is the normal install path. Mammoth.LiteMapper.Generator is the generator implementation package and is not the normal consumer install route.
The generator recognizes these build properties for diagnostics and development:
LiteMapper_EmitDebugMetadata
LiteMapper_IncludeGeneratedSourceComments
LiteMapper_TreatInternalGeneratorErrorsAsExceptions
These options must not change mapping semantics.
Unexpected mapper validation, planning, or rendering failures normally produce one sanitized LITEMAPPER9001 at that mapper while unrelated mappers continue. LiteMapper_TreatInternalGeneratorErrorsAsExceptions=true exposes the original exception through the compiler's generator-failure reporting for development and tests. Exception details can contain local paths; remove sensitive details before sharing a report.
LiteMapper reports compile-time diagnostics for unsupported declarations, invalid configuration, and unsupported mapping shapes. Diagnostic IDs are stable once introduced.
A fatal error in one mapping method leaves that method unimplemented while independent valid methods in the same mapper still generate. Invalid mapper-wide configuration suppresses that mapper's generated methods. Unrelated mapper classes continue generating. Correct the reported errors before building the consumer successfully.
Diagnostics are either configurable severity diagnostics, such as unmapped-member policy diagnostics, or non-configurable hard semantic errors, such as invalid declarations, unsupported shapes, ambiguous mappings, invalid converters, and generator internal failures. Diagnostics are reported at the most specific source location available, such as the mapping method or attribute argument.
Mapping declarations must be synchronous. For example, public static async partial Target Map(Source source); reports LITEMAPPER0006; declare public static partial Target Map(Source source); instead. Perform any asynchronous work before calling the mapper. Task-returning mapping methods and asynchronous converters are unsupported.
Ordinary unmapped source and target members honor Ignore, Info, Warning, and Error exactly. Each reported unmapped-member diagnostic names the exact source or target member, for example Target member 'Extra' is not mapped. With source checking enabled through UnmappedSourceMembers, standard .editorconfig overrides apply to LITEMAPPER1003; target overrides apply to LITEMAPPER1001. An ordinary diagnostic reported as an error still leaves the valid generated implementation available, so downgrading or suppressing it does not suppress generation or prove complete target mapping. Unsatisfied required, non-nullable, inaccessible, invalid, and otherwise mandatory target obligations remain hard non-configurable errors, including members with unapproved initializers; use UseTargetDefault to approve a real default. IgnoreTarget and UseTargetDefault still require their existing validation.
If a project requires mandatory unmapped-target coverage, configure LITEMAPPER1001 as an error in CI and audit or avoid project-wide suppression. LiteMapper cannot prevent an intentional consumer .editorconfig or other compiler severity override while this diagnostic remains configurable.
Configuration errors include LITEMAPPER1006 for an invalid source path, 1007 for a dotted target, 1008 for duplicate target mapping, 1014 for a missing requested default, and 1015 for an invalid ignored member. Selected converter signatures use 2009, converter ambiguity uses 2011, get-only collection updates use 4004, and existing-target arrays use 4005.
A nullable reference or value result from a converter cannot satisfy a non-null target under NullableMismatchPolicy.Error and reports LITEMAPPER2010. Under Throw, the converter is invoked once and a null result throws InvalidOperationException naming the target path. Duplicate visible defaults for the requested pair report LITEMAPPER3002; an inaccessible or incompatible default reports LITEMAPPER3003 instead of silently selecting another mapping. Unsupported generated member types such as dynamic, pointers, function pointers, or ref-like structural members report LITEMAPPER2008; use an explicit handwritten converter where legal C# permits the conversion.
Diagnostic families:
LITEMAPPER0001throughLITEMAPPER0013: mapper declaration and configuration diagnostics.LITEMAPPER1001throughLITEMAPPER1016: construction, member mapping, and source-path diagnostics.LITEMAPPER2001throughLITEMAPPER2012: nullability, conversion, and unsupported generated-type diagnostics.LITEMAPPER3001throughLITEMAPPER3005: nested mapping diagnostics.LITEMAPPER4001,LITEMAPPER4002, andLITEMAPPER4004throughLITEMAPPER4006: collection diagnostics.LITEMAPPER5001throughLITEMAPPER5006: existing-target mapping diagnostics.LITEMAPPER6001: recursive mapping diagnostics.LITEMAPPER7001throughLITEMAPPER7005: enum mapping diagnostics.LITEMAPPER9001: internal generator error diagnostic.
The following are not LiteMapper 1.0 usage features:
- EF Core expression projections.
- Expression-tree converter inlining.
- Runtime polymorphic mapping.
- Runtime mapper registration or assembly scanning.
- Dependency-injection registration extensions.
- Object factories, hooks, or mapping context propagation.
- Open generic mappings or unbound generic registration containers. Closed constructed generic external containers are supported when their methods satisfy the ordinary registration rules.
- External instance mapper resolution.
- Automatic flattening or naming-strategy plugins.
- Custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays.
- Async mapping.
- Runtime logging, telemetry, or network behavior.
Deferred features are either absent from the public API or diagnosed when requested through generated mapping declarations.