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; } = "";Class-level converters
Section titled “Class-level converters”[MapConverter<T>] can also be placed on the DTO type. It then applies to every convention-mapped scalar whose source→target types match the converter’s IValueConverter<TSource, TTarget> signature, with no need to repeat the attribute on each property. A property-level converter always wins over a class-level one.
[MapFrom<Order>][MapConverter<MoneyToStringConverter>] // decimal → string, applied to all matching propspublic partial class OrderDto{ public string Total { get; init; } = ""; // entity.Total (decimal) → converted public string Shipping { get; init; } = ""; // entity.Shipping (decimal) → converted public string Reference { get; init; } = ""; // entity.Reference is string → converter skipped (types don't match)}Converter instances are cached once in a static readonly field per converter type: no per-call allocation, whether applied at the property or the class level.
Requirements
Section titled “Requirements”- Parameterless constructor: The converter must have a
publicparameterless constructor. The generator callsnew TConverter()directly. Missing it reports PRAG0306. - Stateless: Converters should not hold mutable state.
classconstraint: The[MapConverter<TConverter>]attribute requireswhere TConverter : class, new(). If the type does not implementIValueConverter<,>, the generator reports PRAG0305.
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(...)In a Projection
Section titled “In a Projection”SQL cannot run a converter, so the projection selects the source column and runs the converter on the
client after the read, the query’s last step, where EF Core evaluates what it cannot translate. The value
is the one FromEntity gives. The projection calls it through a generated static method
(ConvertAfterTheRead_{Property}), because EF Core refuses a client projection that calls an instance
method on a cached object.
Through a navigation that may be null it cannot ([MapProperty("Guest.Name")] with Guest nullable): the
property is excluded from the projection and receives default (PRAG0320 warning).
[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; } = ""; // Computed on the client after the read}⚠️ The projection is the last step: .Select(InvoiceSummaryDto.Projection).Where(d => d.TotalFormatted …)
asks SQL for a value only the client computes, and EF Core refuses it at run time. Filter on the entity first.
When 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 null-safe assignments: intermediate navigations are created (??= new()) when they have an accessible parameterless constructor, otherwise the assignment is null-guarded (skipped when the navigation is null). No NullReferenceException:
entity.Customer ??= new();entity.Customer.Name = this.CustomerName;
entity.ShippingAddress ??= new();entity.ShippingAddress.City = this.ShippingCity;