Mapwright 0.5.0
Prefix ReservedSee the version list below for details.
dotnet add package Mapwright --version 0.5.0
NuGet\Install-Package Mapwright -Version 0.5.0
<PackageReference Include="Mapwright" Version="0.5.0" />
<PackageVersion Include="Mapwright" Version="0.5.0" />
<PackageReference Include="Mapwright" />
paket add Mapwright --version 0.5.0
#r "nuget: Mapwright, 0.5.0"
#:package Mapwright@0.5.0
#addin nuget:?package=Mapwright&version=0.5.0
#tool nuget:?package=Mapwright&version=0.5.0
Mapwright
The compile-time object mapper. Declare the mapping; the compiler writes it, checks it, and shows you the code.
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.csfiles: object initializers,GetValueOrDefault()where a nullable column collapses, a null-guard before a nested map. Breakpoints work.git diffof behavior is possible. There is no runtime black box to reverse-engineer at 2 a.m. - Nothing happens at runtime. The
Mapwrightpackage 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 — soqueryable.Select(CatalogMapper.SummaryProjection())translates to SQL exactly like a hand-written projection, because it is one.
Install
Two packages: the attributes, and the generator that does the work.
<ItemGroup>
<PackageReference Include="Mapwright" Version="0.5.0" />
<PackageReference Include="Mapwright.Generator" Version="0.5.0" PrivateAssets="all" />
</ItemGroup>
Building from source instead: reference
src/Mapwright/Mapwright.csprojnormally andsrc/Mapwright.Generator/Mapwright.Generator.csprojwithOutputItemType="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. |
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 (0.3): 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 (0.2): 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 (0.2): 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 (0.2): 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 (0.3): 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 (0.3): [Mapper] public partial class M : IM with
instance partial methods works — register the class in DI and inject the interface.
Flattening (0.4): 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 (0.5): [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 (0.4): 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 (0.4): [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 (0.4): 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.
Deep cloning (0.4): [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.
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. |
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 |
Reading it honestly: on a collection — the case that dominates real workloads — Mapwright
is the fastest of all four, ~10% ahead of Mapperly and faster than hand-written LINQ itself,
because its 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 stays ~8%
ahead (less method-call indirection in the tiny-domain case), and AutoMapper's runtime
resolution costs 1.75–2.07× throughout. Run dotnet run -c Release in the benchmarks folder
to reproduce.
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.Sample — dotnet 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.
Honest limitations (v0.4)
- Mapper classes must be non-generic and mapping methods non-generic (instance methods are fine as of 0.3; hook targets stay static).
- No DI-resolved value converters inside maps — that's what hooks and hand-written methods are for.
- Dictionary members, scalar conversions, by-name enum maps and hooks are unavailable inside projections/queryables (expression trees must stay translatable).
- Synthesized maps use default configuration; a pair that needs
[MapIgnore]/renames still wants an explicit declared map (which always takes precedence). - Flattening reads paths; reverse unflattening (writing
ChildNameback intoChild.Name) is not implemented.
Road to Mapperly parity
- 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>)— theProjectToshape (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 & release automation (awaiting the first
v*tag) - Generic mappers.
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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.