Custom Converters Guide
This guide covers IValueConverter<TSource, TTarget> and [MapConverter<T>] for custom property type conversions.
Overview
Section titled “Overview”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().
Implementing IValueConverter
Section titled “Implementing IValueConverter”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])}Example: MoneyToStringConverter
Section titled “Example: MoneyToStringConverter”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;}Example: Generic Enum-to-Int Converter
Section titled “Example: Generic Enum-to-Int Converter”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);}Using [MapConverter<T>]
Section titled “Using [MapConverter<T>]”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.
Combined with [MapProperty]
Section titled “Combined with [MapProperty]”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; } = "";Requirements
Section titled “Requirements”- Parameterless constructor: The converter must have a
publicparameterless constructor. The generator callsnew TConverter()directly. - Stateless: Converters should not hold mutable state. A new instance is created for each mapping call.
classconstraint: The[MapConverter<TConverter>]attribute requireswhere TConverter : class, new().
If these requirements are not met, the generator reports:
| Diagnostic | Description |
|---|---|
| PRAG0305 | Converter does not implement IValueConverter<TSource, TTarget> |
| PRAG0306 | Converter does not have a parameterless constructor |
Converter in [MapTo] and ApplyTo()
Section titled “Converter in [MapTo] and ApplyTo()”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(...)Projection Limitation
Section titled “Projection Limitation”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 layerWhen to Use Converters vs Built-In Conversions
Section titled “When to Use Converters vs Built-In Conversions”| Scenario | Approach |
|---|---|
int to string | Built-in (automatic .ToString()) |
enum to string | Built-in (automatic .ToString()) |
DateTime to DateOnly | Built-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; } = "";}Target Path for [MapTo]
Section titled “Target Path for [MapTo]”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;