Skip to content

Custom Converters Guide

This guide covers IValueConverter<TSource, TTarget> and [MapConverter<T>] for custom property type conversions.

When the built-in type conversions (ToString, Parse, DateTime/DateOnly, etc.) are not sufficient, you can implement a custom converter. Converters are stateless, bidirectional, and instantiated by the generator with new TConverter().

The IValueConverter<TSource, TTarget> interface has two methods:

using Pragmatic.Mapping.Converters;
public interface IValueConverter<TSource, TTarget>
{
TTarget Convert(TSource source); // Entity -> DTO (used by [MapFrom])
TSource ConvertBack(TTarget target); // DTO -> Entity (used by [MapTo])
}

From the Showcase app (examples/showcase/src/Showcase.Billing/Infrastructure/Converters/MoneyToStringConverter.cs):

using Pragmatic.Mapping.Converters;
public sealed class MoneyToStringConverter : IValueConverter<decimal, string>
{
public string Convert(decimal source) => source.ToString("N2");
public decimal ConvertBack(string target) =>
decimal.TryParse(target, out var result) ? result : 0m;
}
public class EnumToIntConverter<TEnum> : IValueConverter<TEnum, int>
where TEnum : struct, Enum
{
public int Convert(TEnum source) => System.Convert.ToInt32(source);
public TEnum ConvertBack(int target) => (TEnum)Enum.ToObject(typeof(TEnum), target);
}

Apply [MapConverter<T>] to a property to use your converter:

using Pragmatic.Mapping.Attributes;
using Pragmatic.Mapping.Converters;
[MapFrom<Invoice>]
public partial class InvoiceSummaryDto
{
public decimal TotalAmount { get; init; }
[MapProperty(nameof(Invoice.TotalAmount))]
[MapConverter<MoneyToStringConverter>]
public string TotalFormatted { get; init; } = "";
}

The generator calls new MoneyToStringConverter().Convert(entity.TotalAmount) in the FromEntity() method.

You can combine [MapConverter<T>] with [MapProperty] to specify both the source path and the converter:

// Source property + custom converter
[MapProperty(nameof(Invoice.TotalAmount))]
[MapConverter<MoneyToStringConverter>]
public string TotalFormatted { get; init; } = "";
// Navigation path + custom converter
[MapProperty("Order.Total")]
[MapConverter<MoneyToStringConverter>]
public string OrderTotal { get; init; } = "";
  1. Parameterless constructor: The converter must have a public parameterless constructor. The generator calls new TConverter() directly.
  2. Stateless: Converters should not hold mutable state. A new instance is created for each mapping call.
  3. class constraint: The [MapConverter<TConverter>] attribute requires where TConverter : class, new().

If these requirements are not met, the generator reports:

DiagnosticDescription
PRAG0305Converter does not implement IValueConverter<TSource, TTarget>
PRAG0306Converter does not have a parameterless constructor

When the DTO also has [MapTo<T>], the ConvertBack method is used:

[MapFrom<Invoice>]
[MapTo<Invoice>]
public partial class InvoiceDto
{
[MapProperty(nameof(Invoice.TotalAmount))]
[MapConverter<MoneyToStringConverter>]
public string TotalFormatted { get; init; } = "";
}
// MapFrom: entity.TotalAmount -> new MoneyToStringConverter().Convert(...)
// MapTo: dto.TotalFormatted -> new MoneyToStringConverter().ConvertBack(...)

Converters are not supported in projections (PRAG0320 warning). Expression Trees cannot contain arbitrary C# method calls — only operations that EF Core can translate to SQL.

If a property has [MapConverter<T>] and the DTO also has [GenerateProjection], that property is excluded from the projection expression and receives default.

[MapFrom<Invoice>]
[GenerateProjection]
public partial class InvoiceSummaryDto
{
public decimal TotalAmount { get; init; } // Included in Projection
[MapProperty(nameof(Invoice.TotalAmount))]
[MapConverter<MoneyToStringConverter>]
public string TotalFormatted { get; init; } = ""; // EXCLUDED from Projection (PRAG0320)
}

Workaround: If you need the converted value in a projection, perform the conversion in the consuming code after the query materializes:

var dtos = await db.Invoices
.Select(InvoiceSummaryDto.Projection)
.ToListAsync();
// TotalFormatted is default("") from projection
// Use TotalAmount for display formatting in the UI layer

When to Use Converters vs Built-In Conversions

Section titled “When to Use Converters vs Built-In Conversions”
ScenarioApproach
int to stringBuilt-in (automatic .ToString())
enum to stringBuilt-in (automatic .ToString())
DateTime to DateOnlyBuilt-in (automatic DateOnly.FromDateTime())
Custom formatting (decimal to "$1,234.56")[MapConverter<T>]
Domain type to primitive (Money to string)[MapConverter<T>]
Encryption/decryption[MapConverter<T>]
Complex transformation (multiple fields)CustomizeMapping() hook

Manual Property Mapping with [MapProperty]

Section titled “Manual Property Mapping with [MapProperty]”

For cases where you do not need a full converter but want explicit control over the source path:

[MapFrom<Reservation>]
public partial class ReservationSummaryDto
{
// Explicit navigation path
[MapProperty("Guest.FirstName")]
public string GuestFirstName { get; init; } = "";
// Concatenation from multiple paths
[MapProperty("Guest.FirstName", "Guest.LastName")]
public string GuestFullName { get; init; } = "";
// Custom separator
[MapProperty("Guest.LastName", "Guest.FirstName", Separator = ", ")]
public string GuestNameReversed { get; init; } = "";
// Default for nullable to non-nullable
[MapProperty(nameof(Reservation.Notes), Default = "")]
public string Notes { get; init; } = "";
}

When mapping back to an entity with nested properties, use the Target parameter:

[MapTo<Order>]
public partial record UpdateOrderDto
{
[MapProperty(Target = "Customer.Name")]
public string CustomerName { get; init; } = "";
[MapProperty(Target = "ShippingAddress.City")]
public string ShippingCity { get; init; } = "";
}

This generates:

entity.Customer.Name = this.CustomerName;
entity.ShippingAddress.City = this.ShippingCity;