Format a value
By default a value object formats as its underlying value. An IBAN is stored in its electronic form, but people read it in groups of four, and logs should only show its last digits. A formatter hook adds named formats.
[ValueObject<string>(MinLength = 15, MaxLength = 34)]
public readonly partial struct Bban : IValueObjectFormatter<string>
{
public static class Formats
{
public const string Electronic = "E";
public const string Masked = "M";
}
public static bool TryFormatValue(
in string value,
Span<char> destination,
out int charsWritten,
ReadOnlySpan<char> format,
IFormatProvider? provider)
{
_ = provider;
if (destination.Length < value.Length)
{
charsWritten = 0;
return false;
}
value.CopyTo(destination);
if (format is "M" or "m")
{
destination[2..(value.Length - 4)].Fill('*');
}
charsWritten = value.Length;
return true;
}
}
var bban = Bban.Create("30006000011234567890189");
bban.ToString() // "30006000011234567890189"
bban.ToString(Bban.Formats.Masked, null) // "30*****************0189"
$"{bban:M}" // the same, through ISpanFormattable, with no intermediate string
The rules of the hook
- It takes over formatting entirely, including the empty and
nullformat. Handle the default case — here, anything butMwrites the value as it is. - Return
falsewhen the destination is too small. The generatedToString(format, provider)grows its buffer and calls again; that is the framework contract, not an error. - The
Formatsclass is a convention, not a requirement: named constants spare callers a magic letter.
IValueObjectFormatter<TValue> writes into a span and allocates nothing. For a rule whose output is naturally a
string, IValueObjectStringFormatter<TValue> takes FormatValue(in value, format, provider) instead; when a type
declares both, the string formatter wins.
Formatting never affects the wire: JSON, the database and model binding always carry the underlying value.