Adding Custom Formatting to Your Classes with IFormattable

DateTime has a great feature that I often replicate in my classes: the ability for users to format the ToString output however they want. Using it is cool, but building it yourself? Even better.

In this post, I’ll walk you through implementing custom formatting for any class using the IFormattable interface, covering multiple approaches.

You can also check out my YouTube video on the same topic here: https://youtu.be/hocPk1eBUuE

What we’re aiming to achieve is similar to the ToString method with format options found in the DateTime class.

Console.WriteLine(DateTime.Now.ToString("(MM) d - yyyy"));

// Output: (11) 18 - 2024

You’ll see that we can pass a format string to the ToString method, and based on what we specify, we get the corresponding data. Plus, you can customize the output by adding multiple “tokens” to the format string.

So, let’s get started by creating a new class called Employee.

public class Employee
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
    public required string JobTitle { get; set; }
    public required string CompanyName { get; set; }
}

Next, we’ll implement the IFormattable interface and add a new ToString method.

public class Employee : IFormattable
{
  // (..)
  public string ToString(string? format, IFormatProvider? formatProvider)
    {
        throw new NotImplementedException();
    }
}

I’ll override the ToString method and create another overload with a single parameter for the format.

public class Employee : IFormattable
{
  // (..)
  public override string ToString()
  {
      return ToString("G"); // G as General format
  }
  public string ToString(string? format)
  {
      return ToString(format, CultureInfo.CurrentCulture); // use CurrentCulture for now
  }
  public string ToString(string? format, IFormatProvider? formatProvider, int x)
  {
        // Let's start
    }
}

Simple Approach

The first approach is straightforward: mapping a “format token” to an actual output. For instance, using the format Login should display FirstName.LastName.

public string ToString(string? format, IFormatProvider? formatProvider)
{
    // G, Full => F L is the JobTitle at CompanyName
    // login, L => firstname.lastname
    // userdomain, ud => [email protected]

    if (string.IsNullOrEmpty(format))
    {
        format = "G";
    }

    format = format.ToUpper();

    return format switch
    {
        "G" or "FULL" => $"{FirstName} {LastName} is the {JobTitle} at {CompanyName}",
        "L" or "LOGIN" => $"{FirstName}.{LastName}",
        "UD" or "USERDOMAIN" => $"{FirstName}.{LastName}@{CompanyName}.local",
        _ => throw new FormatException($"Invalid format {format}")
    };
}

Note: I decided to make the keywords work for both uppercase and lowercase inputs. To do this, I converted format to an uppercase string using ToUpper.

var employee = new Employee()
{
    FirstName = "John",
    LastName = "Smith",
    JobTitle = "CEO",
    CompanyName = "BMW"
};

Console.WriteLine(employee); // John Smith is the CEO at BMW
Console.WriteLine(employee.ToString()); // John Smith is the CEO at BMW
Console.WriteLine(employee.ToString("G")); // John Smith is the CEO at BMW
Console.WriteLine(employee.ToString("Full")); // John Smith is the CEO at BMW

Console.WriteLine(employee.ToString("L")); // John.Smith
Console.WriteLine(employee.ToString("LOgin")); // John.Smith

Console.WriteLine(employee.ToString("Ud")); // [email protected]
Console.WriteLine(employee.ToString("UserDomain")); // [email protected]

Dynamic Approach

This approach gives users the flexibility to include multiple tokens in a single format. For example, a user can specify the first name, initial of the middle name, job title, etc., all within the same string.

public class Person : IFormattable
{
    public required string FirstName { get; set; }
    public string? MiddleName { get; set; }
    public required string LastName { get; set; }
    
    public override string ToString()
    {
        return ToString("G");
    }

    public string ToString(string? format)
    {
        return ToString(format, CultureInfo.CurrentCulture);
    }

    public string ToString(string? format, IFormatProvider? formatProvider)
    {
        // F. LL
        // FF M. LL
        // F,M LL
        // G => full

        if (string.IsNullOrEmpty(format))
        {
            format = "G";
        }

        Dictionary<string, string> mapper = new Dictionary<string, string>()
        {
            { "F", FirstName[0].ToString() },
            { "FF", FirstName },
            { "M", (MiddleName ?? "")[0].ToString() },
            { "MM", MiddleName ?? "" },
            { "L", LastName[0].ToString() },
            { "LL", LastName },
            { "G", $"{FirstName} {MiddleName} {LastName}" }
        };

        // FF M. L
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < format.Length; i++)
        {
            char c = format[i]; // F
            string token = c.ToString(); // F
        
            while (i + 1 < format.Length && c == format[i + 1])
            {
                token += c;
                i++;
            }
        
            if (mapper.TryGetValue(token, out var value))
            {
                sb.Append(value);
            }
            else
            {
                sb.Append(token);
            }
        }
        
        return sb.ToString();
    }
}

Here’s a breakdown of the code:

First, we create a dictionary of tokens mapped to their respective values. For example, F represents the initial of the first name, and FF represents the full first name.

Then, we loop through the format string, checking each character for matching tokens. We also handle cases where a token could be part of a longer token (e.g., F vs FF).

Once we identify a token, we retrieve its value from the dictionary and append it to a StringBuilder.

Testing this approach gives us the following output:

var person = new Person()
{
    FirstName = "Jack",
    MiddleName = "John",
    LastName = "Smith"
};

Console.WriteLine(person.ToString("G")); // Jack John Smith
Console.WriteLine(person.ToString("FF M. LL")); // Jack J. Smith
Console.WriteLine(person.ToString("FM LL")); // JJ Smith

Flexible Approach

This approach provides even more flexibility by allowing users to append external text to the format string without disrupting the output. For instance, if we try formatting a string like this, it will break:

var person = new Person()
{
    FirstName = "Jack",
    MiddleName = "John",
    LastName = "Smith"
};

Console.WriteLine(person.ToString("Man, L is the LOSER"));
// Jan, S is the SOSER

Notice how the M in “Man” was replaced by the middle name initial, and the L in “Loser” was replaced by the last name initial. To solve this, we can use a different method by wrapping tokens in curly braces {X}.

public string ToString(string? format, IFormatProvider? formatProvider)
{
  // F. LL
  // FF M. LL
  // F,M LL
  // G => full
  
  if (string.IsNullOrEmpty(format))
  {
      format = "{G}";
  }
  
  Dictionary<string, string> mapper = new Dictionary<string, string>()
  {
      { "{F}", FirstName[0].ToString() },
      { "{FF}", FirstName },
      { "{M}", (MiddleName ?? string.Empty)[0].ToString() },
      { "{MM}", MiddleName ?? string.Empty },
      { "{L}", LastName[0].ToString() },
      { "{LL}", LastName },
      { "{G}", $"{FirstName} {MiddleName} {LastName}" }
  };
  
  foreach (var key in mapper.Keys)
  {
      format = format.Replace(key, mapper[key]);
  }
  
  return format;
}

Now, instead of iterating through each character in the format string as an array, we loop through the dictionary keys and replace each key in the format string with its corresponding value.

Console.WriteLine(person.ToString("Man, {F}.{L}. is the LOSER"));

// Man, J.S. is the LOSER

With these techniques, you can add custom formatting to your classes and make your code more flexible and easy to use.

Happy Coding!

Recent Posts

HybridCache in .NET 9 is Awesome!
.NET 9 is now live, and it comes with a new set of features. Some are great, and some are just icing on the cake. …
Azure-Sync: Sync your Azure App Settings to local
Azure-Sync is a handy shell script tool designed to help .NET developers working with Azure App Services. Inspired by the functionality provided by the Azure …
Implement Builders easily with Source Generator in .NET
I created a YouTube video on Source Generator in which I showcased one possible implementation. However, I feel that I didn’t fully highlight its capabilities. …
Secure On-Premise .NET Application with Azure Key Vault
Suppose you have your Web App and Database server hosted locally on your On-Premises servers. You want to use Azure Key Vault with your .NET …

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.