Implementing ICustomFormatter in C#

Updated on 2020-03-13

How to provide custom formatters for string.Format() in C#

Introduction

.NET provides a rich string formatting API using string.Format() to provide all kinds of formatting options for displaying data. Sometimes, you may want to extend it with additional formats. This article aims to show you how.

Conceptualizing this Mess

When formatting strings in .NET, you can use format specifiers to give you additional control over how an argument is converted to a string. This is typically used for formatting numbers, but it can be used for any data type. For example, string.Format("{0:d}",1) will format 1 as a Decimal value. There are several default format specifiers. See the Microsoft .NET documentation for more details.

Microsoft .NET documentation

We will be making our own format specifier, "ue" (example: "{0:ue}") which will URL encode a string value. You can then use string.Format() with the ue specifier to URL encode an argument.

Coding this Mess

To implement a custom formatter, we must implement two interfaces: IFormatProvider and ICustomFormatter. In virtually all cases, both of these interfaces will be implemented by the same class.

Let's explore the code for UriEncodeFormatter:

/// <summary>
/// Provides uri encoding of a formatted value using "{arg#:ue}" such as "{0:ue}"
/// </summary>
sealed class UriEncodeFormatter : IFormatProvider, ICustomFormatter
{
    public static readonly UriEncodeFormatter Default = new UriEncodeFormatter();
    public object GetFormat(Type type)
    {
        // boilerplate
        if (typeof(ICustomFormatter) == type)
            return this;
        else
            return null;
    }

    public string Format(string format, object arg, IFormatProvider formatProvider)
    {
        // if "ue" isn't specified, then try other formatters
        if ("ue" != format)
            return _FormatOther(format, arg);

        // if "ue" is specified and arg is a non-empty string then url encode
        // the value
        var s = Convert.ChangeType(arg ,typeof(string)) as string;
        if (null == s) // sanity
            s = "";
        return Uri.EscapeDataString(s);
    }

    string _FormatOther(string format, object arg)
    {
        // try to format using a default formatter
        var fmt = arg as IFormattable;
        if (null!=fmt)
            return fmt.ToString(format, CultureInfo.CurrentCulture);
        else if (null!=arg)
            return arg.ToString();
        else
            return string.Empty;
    }
}

Let's address it top to bottom:

First, we have the Default field. Since making multiple instances is pointless, we just create a single instance you can get to through UriEncodeFormatter.Default which we'll be using later.

Next, we come to IFormatProvider's GetFormat() implementation. This is boilerplate code. All we do is check if the type requested is ICustomFormatter and return a reference to this instance if it is, otherwise the method returns null. This code will be the same on most implementations of a custom formatter.

Moving on, we have ICustomFormatter's Format() method. This is where the meat of our formatting implementation goes. First, we short-circuit if the format is not "ue". In this case, we try the default formatter chain by delegating to _FormatOther(). After that check, the argument is converted to a string which is then escaped using Uri.EscapeDataString(). In your own formatter, you'll do something else in Format().

Finally, we have _FormatOther() which is a helper that simply tries the default formatter chain passing the specified format string and argument. This code will look the same in most, if not all of your formatters you write.

Using it is simple:

string.Format(UriEncodeFormatter.Default, "{0:ue}", "Hello World!")

The idea is to call string.Format() passing your custom formatter instance as the first argument, followed by your format string (including custom format specifiers like "ue"), trailed by your arguments for the format string.

That's all there is to it.

History

  • 13th March, 2020 - Initial submission