Roslyn source generator that emits MapTo{Target} extension methods — in both
directions — for classes annotated with [GenerateMapper]. Properties are matched
by exact name and type; unmatched properties are silently skipped.
dotnet add package HK.MapperGeneratorRequires a Roslyn 4.14+ toolchain (Visual Studio 2022 17.14+ / .NET SDK 9.0.3xx+) to build the consuming project — the generator itself targets any project that can reference an analyzer.
public class Entidade
{
public int Id { get; set; }
public string Nome { get; set; }
}
[GenerateMapper(typeof(Entidade))]
public class EntidadeDto
{
public int Id { get; set; }
public string Nome { get; set; }
}using HK.MapperGenerator; // GenerateMapperAttribute
using GeneratedMappers; // MapperExtensions
var entidade = new Entidade { Id = 1, Nome = "Teste" };
EntidadeDto dto = entidade.MapToEntidadeDto();
// Reverse direction, generated from the same annotation:
Entidade roundTrip = dto.MapToEntidade();The generator adds [GenerateMapper] to your compilation automatically — you
don't need to declare it yourself. It emits one static
GeneratedMappers.MapperExtensions class per compilation with two
MapTo{Target} extension methods per annotated class: one for each direction.
If the destination type has no public setters (e.g. Entidade above used
constructor-only or init properties), the generator picks a public
constructor whose parameters match source properties by name and type, and
fills any remaining writable properties via an object initializer. If no
constructor can be satisfied, that direction is skipped and reported as HKMG002.
No extra annotation is needed beyond the ones you already have. If a property's type doesn't match exactly but a pair for it is also annotated somewhere in the compilation, the generator composes automatically:
public class Address { public string Street { get; set; } = ""; }
[GenerateMapper(typeof(Address))]
public class AddressDto { public string Street { get; set; } = ""; }
public class Person
{
public Address Address { get; set; } = new();
public List<Address> PreviousAddresses { get; set; } = new();
}
[GenerateMapper(typeof(Person))]
public class PersonDto
{
public AddressDto Address { get; set; } = new();
public List<AddressDto> PreviousAddresses { get; set; } = new();
}Both Address and PreviousAddresses map deeply — a new AddressDto/Address
instance per element, not a reference copy — because Address <-> AddressDto
is already a known pair. Matching priority, first one that fits wins: (1)
exact type (including reference copy when both sides use the identical type),
(2) nested object 1:1, (3) collection element-by-element.
Collections: List<T>, arrays, and any of IEnumerable<T> / ICollection<T> /
IList<T> / IReadOnlyCollection<T> / IReadOnlyList<T> as the destination
type — all materialize via .ToList() (arrays via .ToArray()). Anything else
on the destination side (HashSet<T>, Collection<T>, a custom collection
type) isn't a supported materialization target and the property is skipped,
same as before composition existed — this never turns into a compile error.
string is never treated as a collection of char, despite implementing
IEnumerable<char>.
Cyclic graphs. The mapper does no reference tracking (no PreserveReferences
equivalent). A property whose type maps back to an ancestor in the composition
graph — most commonly self-reference, e.g. Person.Manager of type Person —
still gets a mapping method, but calling it on data that actually forms a cycle
at runtime will stack-overflow, the same way any hand-written recursive
mapper would. The generator reports HKMG005 on any pair that participates in
such a cycle so you know the risk exists; it does not block emission, because
acyclic uses (a finite management chain, a tree of categories) are legitimate.
- Mapping is by exact name and type match; properties without a matching source property are left at their default value.
- Nullability annotations are not part of the type match —
string?matchesstring. This is deliberate: the generator only encodes what the CLR type is, not nullable-context metadata. - Only non-static, public target properties with a
setorinitaccessor are considered (minus any consumed by the chosen constructor). Inherited properties (from any base class up to but not includingobject) count too, on both source and destination. class, positionalrecord, andstructcan all be annotated. Generic types (open or annotated with a generic argument) are not supported — the pair is skipped and reported asHKMG001.- A class annotated with its own type, or a
partialclass whose declarations carry attributes on more than one part, still emits each direction once — duplicate (source, target) pairs are deduplicated before emission. - Any change to any annotated class regenerates the whole output file (the generator collects all candidates before emitting), though the underlying per-type model is cached incrementally.
- The generated extension methods live in
GeneratedMappers, a namespace deliberately separate fromHK.MapperGenerator(where the attribute itself lives), so ausing GeneratedMappers;is always needed alongsideusing HK.MapperGenerator;.
All warnings, never errors — the generator never fails a build that already compiled.
| ID | Meaning |
|---|---|
HKMG001 |
Source or target is a generic type; the pair was skipped. |
HKMG002 |
No accessible constructor of the destination could be satisfied from the source; that direction was skipped. |
HKMG003 |
Two destinations produced the same MapTo{Name} signature from the same source; the second was skipped. |
HKMG004 |
The argument passed to [GenerateMapper] is not a named type. |
HKMG005 |
The pair participates in a composition cycle (nested object or collection) between generated methods; risk of infinite recursion at runtime. |
GenerateMapperAttributeis injected asinternal sealed, so it does not cross an assembly boundary — you cannot reference it from a different assembly than the one the generator is applied to. This matches the analyzer-only distribution model (no runtime library is shipped).
dotnet build HK.MapperGenerator.slnx
dotnet build HK.MapperGenerator.slnx -c Release
dotnet test HK.MapperGenerator.slnxRoslyn caches analyzer DLLs in the IDE/build server. If a consumer project keeps seeing stale generator output after a rebuild:
dotnet build-server shutdownAdd to the consuming project's .csproj:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>Generated files then appear under obj/<Configuration>/<TFM>/generated/.
dotnet build-server shutdown
dotnet pack src/HK.MapperGenerator/HK.MapperGenerator.csproj -c ReleaseProduces artifacts\HK.MapperGenerator.<version>.nupkg.
Point a throwaway consumer project at the local artifacts folder instead of nuget.org:
<!-- nuget.config next to the consumer project -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="C:\path\to\HK.MapperGenerator\artifacts" />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>dotnet new console -o ConsumerApp
cd ConsumerApp
dotnet add package HK.MapperGenerator --version <version>
dotnet runRequires a nuget.org account and an API key (scope: Push new packages and
package versions, glob HK.*).
dotnet build-server shutdown
dotnet pack src/HK.MapperGenerator/HK.MapperGenerator.csproj -c Release
dotnet nuget push artifacts\HK.MapperGenerator.<version>.nupkg `
--api-key <YOUR_API_KEY> `
--source https://api.nuget.org/v3/index.jsonPublishing is irreversible — a pushed version cannot be re-uploaded, only unlisted.
.github/workflows/publish.yml pushes to nuget.org whenever a tag matching
v* is pushed. Requires a NUGET_API_KEY secret in the repository
(Settings → Secrets and variables → Actions).
git tag v0.1.0
git push origin v0.1.0Edit <Version> in src/HK.MapperGenerator/HK.MapperGenerator.csproj, then
repeat the pack/push steps above with a matching git tag.