Mapwright 1.3.1

Prefix Reserved
dotnet add package Mapwright --version 1.3.1
                    
NuGet\Install-Package Mapwright -Version 1.3.1
                    
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="Mapwright" Version="1.3.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Mapwright" Version="1.3.1" />
                    
Directory.Packages.props
<PackageReference Include="Mapwright" />
                    
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 Mapwright --version 1.3.1
                    
#r "nuget: Mapwright, 1.3.1"
                    
#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 Mapwright@1.3.1
                    
#: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=Mapwright&version=1.3.1
                    
Install as a Cake Addin
#tool nuget:?package=Mapwright&version=1.3.1
                    
Install as a Cake Tool

Mapwright

The compile-time object mapper. Declare the mapping; the compiler writes it, checks it, and shows you the code.

CI NuGet License: MIT .NET Standard 2.0 | 8 | 10

Migrating off AutoMapper? Mapwright is MIT-licensed and free for commercial use, with no runtime component to license, register, or warm up. Every piece of a Profile has a direct replacement, and the runtime check you had to remember to write becomes the build:

AutoMapper Mapwright
Profile + CreateMap<TSource, TDest>() a [Mapper] class with one static partial method per mapping
IMapper injected everywhere call the generated method directly — or an instance mapper behind an interface when you want DI
ProjectTo<TDest>() a Expression<Func<TSource, TDest>> or IQueryable<TDest> partial — EF translates it because it is a hand-written projection
IncludeAllDerived() [MapDerivedType(typeof(CarEntity), typeof(CarDto))]
BeforeMap / AfterMap [BeforeMap] / [AfterMap]
custom value resolvers needing services a hand-written method on the mapper; on an instance mapper it can use constructor-injected dependencies
AssertConfigurationIsValid() in a test the compiler, on every keystroke — and a rename that orphans an ignore is a build error

Start with the migration walkthrough, or the side-by-side reference whose sample output is printed from a real runnable app. Weighing the alternatives? Mapwright vs Mapperly vs AutoMapper is honest about where each one wins — including when to stay where you are.

Don't rewrite the Profiles by hand. dotnet add package Mapwright.Migration adds an analyzer that offers "Convert to a Mapwright mapper" on every AutoMapper.Profile, writing the equivalent [Mapper] class beside it and flagging — never dropping — anything it cannot translate. It is a build-time-only package you remove when the migration is done. See migrating with the analyzer.

AutoMapper resolves mappings at runtime, by reflection and convention: a renamed property maps to nothing, silently, until AssertConfigurationIsValid() fails in a test — or nobody notices at all. Mapwright inverts the model. You declare each mapping as a static partial method; a Roslyn incremental source generator writes the implementation as plain C# you can read, diff, and step through; and every destination property that nothing maps is a compiler diagnostic, not a runtime surprise.

[Mapper]
public static partial class CatalogMapper
{
    // Domain record -> EF entity. Audit fields are the database's business.
    [MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created), nameof(CodeEntity.Modified))]
    public static partial CodeEntity ToEntity(Code source);

    // EF entity -> domain record. bool? columns collapse to non-nullable domain booleans.
    public static partial Code ToDomain(CodeEntity source);

    // In-place scalar copy onto the EF-tracked instance — the Repository.Update pattern.
    [MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created), nameof(CodeEntity.Modified))]
    public static partial void CopyScalars(CodeEntity source, CodeEntity target);

    // The ProjectTo replacement: an expression EF Core translates to SQL.
    public static partial Expression<Func<CodeEntity, CodeSummary>> SummaryProjection();

    // Collection overload, wired to the ToEntity map above.
    public static partial List<CodeEntity> ToEntities(IEnumerable<Code> source);
}

That is the entire mapping layer. No profiles, no MapperConfiguration, no IMapper to inject, nothing to register in DI. Call CatalogMapper.ToEntity(code) like the ordinary method it is.

Why compile-time

  • Verification is the build. AutoMapper's AssertConfigurationIsValid() runs when a test remembers to call it. Mapwright's equivalent (MW0001) runs on every keystroke, in the IDE, before the code ever executes — and stale configuration is an error: [MapIgnore("RemovedLastSprint")] referring to a property that no longer exists fails the build (MW0003), so ignore lists cannot rot.
  • The mapping is code you own. Generated implementations land in ordinary .g.cs files: object initializers, GetValueOrDefault() where a nullable column collapses, a null-guard before a nested map. Breakpoints work. git diff of behavior is possible. There is no runtime black box to reverse-engineer at 2 a.m.
  • Nothing happens at runtime. The Mapwright package is attributes only. No reflection, no expression compilation, no cache warm-up — which also means Native AOT and trimming just work, and the only allocation is the destination object itself.
  • EF Core projections without the magic. A Expression<Func<TSource, TDest>> partial method generates the whole projection shape — nested maps inlined as initializers — so queryable.Select(CatalogMapper.SummaryProjection()) translates to SQL exactly like a hand-written projection, because it is one.

Install

dotnet add package Mapwright

That is the whole install. Mapwright holds the attributes; it depends on Mapwright.Generator, the build-time analyzer that writes the code, so one reference brings both. (Referencing Mapwright.Generator explicitly, as versions before 1.2.0 required, still works — NuGet resolves the package once either way.)

Supported frameworks: the attributes package targets .NET Standard 2.0, .NET 8, and .NET 10, so it references cleanly from .NET Framework 4.6.1+, .NET Core, and modern .NET alike. The generator is a build-time analyzer — it ships no lib/, adds no runtime dependency, and adapts the code it writes to whatever your project targets.

Two requirements on older targets, both one-time: set <LangVersion>10.0</LangVersion> (or latest), because the generated file uses file-scoped namespaces and nullable annotations that the C# 7.3 default predates; and use an SDK-style project, because Roslyn does not run source generators in legacy non-SDK .csproj files. Verified end to end on .NET Framework 4.8 with the .NET 9 SDK: it builds, runs, and the generator falls back to classic null guards and Add loops where the modern APIs are absent.

Building from source instead: reference src/Mapwright/Mapwright.csproj normally and src/Mapwright.Generator/Mapwright.Generator.csproj with OutputItemType="Analyzer" ReferenceOutputAssembly="false".

The mapping shapes

Declare Get
static partial TDest Name(TSource s) Object map: initializer, null-guard, ArgumentNullException on null source.
static partial void Name(TSource s, TDest t) In-place copy for EF-tracked instances; init-only members are flagged (MW0006), never half-copied. With ExistingTargets = Merge, nested objects and keyed collections are updated in place.
static partial Expression<Func<TSource, TDest>> Name() EF-translatable projection; nested maps inlined, cycles rejected (MW0007).
static partial List<TDest> Name(IEnumerable<TSource> s) Collection map delegating to the sibling (or synthesized) object map. Arrays and sets too.
static partial IQueryable<TDest> Name(IQueryable<TSource> s) The ProjectTo replacement: source.Select(...) with the whole shape inlined, EF-translatable.

Matching: case-insensitive by name (CodeID → CodeId needs nothing), [MapProperty] for real renames, inherited members included, T? → T collapses via visible GetValueOrDefault(), nested objects and collections route through sibling maps.

Construction: the generator picks the public constructor with the most parameters whose every parameter matches a source member — so positional records map through their primary constructor, remaining members flow through the object initializer, and classes keep their parameterless behavior. required members must be mapped or the build fails (MW0012); a destination nothing can construct is MW0010.

Enums: enum-to-enum members map by numeric cast by default, or by name with [Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] — a generated, readable switch; source members with no same-named destination member are flagged (MW0011). Projections always cast (expression trees cannot contain switches).

Dictionaries & sets: Dictionary<K,V>/IDictionary/IReadOnlyDictionary members map when keys agree and values are identical or element-mappable; HashSet<T>/ISet<T>/IReadOnlySet<T> join the supported collection shapes.

Auto-synthesized nested maps: a nested object or element pair with no declared sibling map gets a private helper map synthesized in the generated file — readable, steppable, and reported on (MW0001 still fires inside it). Declared maps always win; cyclic object graphs resolve to recursive calls. Opt out per mapper with [Mapper(AutoNestedMaps = false)] to restore the strict declare-every-pair contract.

Instance & interface mappers: [Mapper] public partial class M : IM with instance partial methods works — register the class in DI and inject the interface.

Flattening: CustomerName resolves to Customer.Name by convention when no direct member matches (up to three hops, null-guarded), and [MapProperty] accepts dotted source paths ([MapProperty("Customer.Id", "Buyer")]).

Unflattening: [MapProperty] also accepts dotted target paths — [MapProperty("CustomerName", "Customer.Name")] builds the nested destination object, grouping every rename that shares a head and recursing for deeper paths. Works in projections. And rename targets are now rot-checked like everything else: a [MapProperty] pointing at a member that no longer exists fails the build (MW0003).

Scalar conversions: numbers, dates, Guid, bool and enums convert to string; string parses to all of them; DateTime ↔ DateOnly/TimeOnly bridge — all culture-invariant, dates via round-trip "o". A hand-written method for the pair always wins; projections stay conversion-free so the tree remains translatable.

Derived types: [MapDerivedType(typeof(CarEntity), typeof(CarDto))] turns an object map into a runtime-type switch; unmatched types map as the base pair when constructible and throw otherwise (invalid pairs are MW0013 build errors).

Custom methods: any hand-written static method on the mapper with shape TDest Name(TSource) is picked up as the map for that pair — nested resolution and scalar overrides use it automatically. On an instance mapper the method may be an instance method, so a constructor-injected service can act as a value converter; static maps and projections still only ever bind static custom methods, because neither can call one.

Generic mappers: declare TTarget Map<TSource, TTarget>(TSource source) (or void Update<TSource, TTarget>(TSource source, TTarget target)) alongside the concrete maps and the generator emits a typeof dispatch chain over every map declared on the class. Each closed instantiation folds to a single direct call; a pair with no declared map throws an ArgumentException naming both types. Type-parameter constraints are honored — a pair that cannot satisfy them is never an arm.

Deep cloning: [Mapper(DeepClone = true)] makes same-type reference members (and their collections/dictionary values) clone through synthesized maps instead of copying references — T Clone(T source) duplicates the whole graph.

Graph merge (1.3): an in-place copy normally replaces the nested objects and collections the target already holds — which, on an EF Core tracked aggregate, turns an update into a delete-and-insert of every child row. [Mapper(ExistingTargets = ExistingTargetStrategy.Merge)] updates the existing graph instead: a nested object the target already has is copied into, not replaced, and a collection is merged by key — matching elements updated in place, new ones added, elements the source no longer contains removed. The key is Id, Key, or <TypeName>Id by convention (LineId on LineEntity); a collection with no recognizable key is a build error (MW0016), never a silent replace. One collection can opt in on its own, with its own key and without removals:

[MergeCollection(nameof(Order.Lines), Key = nameof(OrderLine.Sku), RemoveMissing = false)]
public static partial void Patch(OrderDto source, Order target);

Get-only collections (public ICollection<Line> Lines { get; } = new List<Line>();, the EF idiom) are merged into as well, since a merge never assigns the member. List<T>, HashSet<T>, IList<T>, ICollection<T> and ISet<T> targets merge; arrays and read-only interfaces are replaced as before. The generated merge is a dictionary of existing elements by key, one loop, and a RemoveAll — code you can read and step through, like everything else. Cyclic instance graphs are safe: EF back-references (Line.Order) are the norm on a tracked aggregate, so a merge tracks the target instances it has already updated and merges each one once per call. Neither Mapperly nor AutoMapper without its Collection extension does this.

Escape hatches: [BeforeMap] runs before, [AfterMap] after the generated assignments — for computed values and everything a generator shouldn't guess.

The diagnostics

Id Severity Meaning
MW0001 Warning Destination property is not mapped — map it, [MapIgnore] it, or set it in AfterMap.
MW0002 Info Source property is never read — [MapIgnoreSource] documents one-way fields.
MW0003 Error Ignore/rename names a property that does not exist (configuration rot).
MW0004 Error No conversion between the matched property types.
MW0005 Error Method signature is not a recognized mapping shape.
MW0006 Warning Init-only property cannot be set by an in-place copy.
MW0007 Error Projection would recurse forever.
MW0008 Error Collection map lacks its element map.
MW0009 Error AfterMap target is missing or has the wrong signature.
MW0010 Error Destination has no public constructor whose parameters all match source members.
MW0011 Warning By-name enum map: a source member has no same-named destination member.
MW0012 Error A required member is neither mapped nor passed through a constructor.
MW0013 Error A [MapDerivedType] pair does not derive from the base pair, or has no map.
MW0014 Error BeforeMap target is missing, or used on a projection.
MW0015 Error A generic mapping method is not a recognized dispatch shape.
MW0016 Error A merged collection has no recognizable key — name one with [MergeCollection(..., Key = ...)].
MW0017 Error The merge key is missing on one element type, or its types differ.
MW0018 Error [MergeCollection] names something that cannot be merged in place (array, read-only interface, non-collection, or a method that is not an in-place copy).

Promote MW0001 to an error in .editorconfig when you want AutoMapper-strictness:

dotnet_diagnostic.MW0001.severity = error

Performance

Generated code runs at hand-written speed, because it is hand-written-style code. benchmarks/Mapwright.Benchmarks maps an order with a nested customer and ten lines, four ways (BenchmarkDotNet, .NET 10, Intel i5-1035G1; read the ratios and allocations — absolute nanoseconds are machine-specific):

Method Mean Ratio Allocated
Hand-written, single order 117.6 ns 1.00 768 B
Mapwright, single order 105.3 ns 0.90 696 B
Mapperly, single order 96.6 ns 0.82 696 B
AutoMapper 13, single order 242.8 ns 2.07 888 B
Hand-written, 100 orders 13.2 µs 1.00 77,728 B
Mapwright, 100 orders 10.8 µs 0.92 70,456 B
Mapperly, 100 orders 12.0 µs 1.02 70,456 B
AutoMapper 13, 100 orders 20.6 µs 1.75 90,992 B

On a collection — the case that dominates real workloads — Mapwright is the fastest of the four: ~10% ahead of Mapperly and ahead of hand-written LINQ itself, because the element loop writes straight into a pre-sized List backing span (CollectionsMarshal.SetCount + AsSpan) — no enumerator, no per-element delegate, no Add capacity checks. Allocations tie Mapperly exactly. On a single object Mapperly keeps a ~8% edge (less call indirection in a tiny domain), and AutoMapper pays 1.75–2.07× for runtime resolution across the board. dotnet run -c Release in the benchmarks folder reproduces the table.

The span path needs CollectionsMarshal.SetCount, so it is what .NET 8 and later get; targets without it get the same loop pre-sized with Add instead — same result, same allocations, one extra bounds check per element.

Replacing AutoMapper

docs/automapper-vs-mapwright.html — the side-by-side migration reference: why AutoMapper exists, the issues it leaves open, how Mapwright closes each with modern C#, a statement-by-statement replacement guide, and a runnable sample app whose real output is printed in the doc (samples/CatalogApi.Sampledotnet run --project samples/CatalogApi.Sample).

docs/replacing-automapper.html is the full walkthrough, built on a real production case study: a catalog API whose AutoMapper MapperFactory (entity↔domain maps, self-maps for tracked updates, ForMember(...Ignore()) lists) became three Mapwright declarations per entity — with the runtime AssertConfigurationIsValid() test replaced by the compiler.

docs/automapper-and-mapwright-explained.html — a from-zero explanation of what AutoMapper is and how source-generated mapping replaces it.

Milestones

Every Mapperly-parity milestone is done; from here the roadmap is Mapwright's own. 1.0 means the attribute surface and generated-code contract are stable: additions are welcome, but a mapping that compiles today will keep compiling and keep meaning the same thing. Breaking changes wait for 2.0.

  • Constructor & positional-record mapping; required-member enforcement (0.2)
  • Enum maps, by value and by name (0.2)
  • Dictionaries and sets (0.2)
  • Auto-synthesized nested maps — undeclared pairs get private helper maps (0.3)
  • Instance & interface mappers for DI (0.3)
  • IQueryable<TDest> Name(IQueryable<TSource>) — the ProjectTo shape (0.3)
  • Flattening + dotted [MapProperty] paths (0.4)
  • Derived-type / polymorphic mapping (0.4)
  • Built-in scalar conversions, culture-invariant (0.4)
  • Hand-written method resolution (0.4)
  • Deep cloning (DeepClone = true) and [BeforeMap] (0.4)
  • Unflattening — dotted [MapProperty] target paths; rename-target rot detection (0.5)
  • Benchmarks vs AutoMapper and Mapperly; incremental-caching regression test (0.5)
  • NuGet packaging & keyless release automation — live on NuGet.org (0.5)
  • Generic mappersTTarget Map<TSource, TTarget>(TSource) dispatching to the declared maps, plus the existing-target void Update<TSource, TTarget>(TSource, TTarget) (0.6)
  • Hand-written instance methods as custom maps — DI-resolved value converters on instance mappers (0.6)
  • Multi-targeting — netstandard2.0/net8.0/net10.0; the generator adapts the code it writes to whatever the consuming project targets (1.0)
  • Graph mergeExistingTargets = Merge and [MergeCollection]: in-place copies update nested instances and merge collections by key instead of replacing them (1.3)

Project layout

Project What it is
src/Mapwright The attributes. The entire runtime surface — nothing executes.
src/Mapwright.Generator The Roslyn incremental generator and its diagnostics.
tests/Mapwright.Tests Behavior tests over real generated mappers + diagnostic tests driving the generator in-memory.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
1.3.1 64 9/2/2026
1.3.0 63 9/2/2026
1.2.0 88 8/29/2026
1.1.0 82 8/28/2026
1.0.0 86 8/28/2026
0.6.0 84 8/27/2026
0.5.0 91 8/25/2026