Learn FormattableString (C#)

Karen Payne - Sep 2 - - Dev Community

Introduction

FormattableString type, introduced with .NET 4.6 which represents a composite format string, along with the arguments to be formatted.

A composite format string consists of fixed text intermixed with indexed placeholders, called format items, that correspond to the objects in the list. The formatting operation yields a result string that consists of the original fixed text intermixed with the string representation of the objects in the list.

Notes

In the code samples, Spectre.Console NuGet package is used to colorize output which allows ease of reading what is displayed.

There are three projects, one for basic usage for FormattableString, a second project for templating and a third project for working with Microsoft Entity Framework Core raw, unmapped queries working with a SQL Server database.

Source code

All code was done in console projects NET8 Core. Source is broken into three projects. The EF Core project requires a database to be created then populated with a provided script.

Main project EF Core project Templating project

Simple example

In the following example a FormattableString is created with a string and two DateOnly variables plus a string for current time of day. Once create a foreach is used to iterate the compose string, when a DateOnly is found display the value.

Output for above description



public static void TheWeekend()
{

    PrintCyan();

    var name = "Karen";
    var (saturday, sunday) = DateTime.Now.GetWeekendDates();
    FormattableString template = FormattableStringFactory
        .Create("{0} [green]{1}[/], this weekend dates are [lightslateblue]{2} {3}[/]",
            Greetings.TimeOfDay(), name, saturday, sunday);

    AnsiConsole.MarkupLine($"{template}");

    foreach (var item in template.GetArguments())
    {
        if (item is DateOnly date)
        {
            Console.WriteLine($"    {date}");
        }
    }

    Console.WriteLine();

}


Enter fullscreen mode Exit fullscreen mode

In the code, emphasis is on the method for FormattableString, GetArguments (and there is also GetArgument method which will be used later) which returns an object array that contains one or more objects. In this case, argument 0 contains a greeting, argument 1 contains Karen while the last two are weekend dates of type DateOnly.

Slight change, FormattableStringFactory.Create can be replace with String interpolation and rather than separating format and values, combine format and value as shown here.



public static void TheWeekend()
{
    PrintCyan();

    var name = "Karen";
    var (saturday, sunday) = DateTime.Now.GetWeekendDates();
    FormattableString template = 
        $"{Greetings.TimeOfDay()} [green]{name}[/], this weekend dates are [lightslateblue]{saturday} {sunday}[/]";

    AnsiConsole.MarkupLine($"{template}");

    foreach (var item in template.GetArguments())
    {
        if (item is DateOnly date)
        {
            Console.WriteLine($"    {date}");
        }
    }

    Console.WriteLine();
}


Enter fullscreen mode Exit fullscreen mode

From FormattableString to string

When there is a need for a string created with FormattableString simple use .ToString().



public static void VersionFormat()
{

    PrintCyan();

    Version version = new(1, 2, 3, 4);
    FormattableString template = FormatVersion(version);
    AnsiConsole.MarkupLine($"[white on blue]{template}[/]");
    string templateString = template.ToString();
    Console.WriteLine();

}


public static FormattableString FormatVersion(Version version) =>
    FormattableStringFactory.Create("{0}.{1}.{2}.{3}", version.Major, version.Minor, version.Build, version.Revision);
}


Enter fullscreen mode Exit fullscreen mode

Deconstruction

Using FormattableString.GetArgument method it is possible to retrieve values from a FormattableString by passing the appropriate index to GetArgument.

Keeping with Version, rather than simply indexing the following extension methods provide meaningful names rather than using an index directly in code. With that some will not care for this, those who are either purist or dislike language extension methods, its a personal choice as the author prefers the clarity of extension methods.



public static class FormattableExtensions
{
    public static int Major(this FormattableString sender)
        => Convert.ToInt32(sender.GetArgument(0));
    public static int Minor(this FormattableString sender)
        => Convert.ToInt32(sender.GetArgument(1));
    public static int Build(this FormattableString sender)
        => Convert.ToInt32(sender.GetArgument(2));
    public static int Revision(this FormattableString sender)
        => Convert.ToInt32(sender.GetArgument(3));
}


Enter fullscreen mode Exit fullscreen mode

Code sample



public static void VersionFormat()
{

    PrintCyan();

    Version version = new(1, 2, 3, 4);
    FormattableString template = FormatVersion(version);
    //AnsiConsole.MarkupLine($"[white on blue]{template}[/]");

    DeconstructVersion(template);

    Console.WriteLine();
}


public static void DeconstructVersion(FormattableString sender)
{
    AnsiConsole.MarkupLine(
        $"[yellow]Major[/] {sender.Major()} " +
        $"[yellow]Major[/] {sender.Minor()} " +
        $"[yellow]Build[/] {sender.Build()} " +
        $"[yellow]Revision[/] {sender.Revision()}");
}

public static FormattableString FormatVersion(Version version) =>
    FormattableStringFactory.Create("{0}.{1}.{2}.{3}", version.Major, version.Minor, version.Build, version.Revision);


Enter fullscreen mode Exit fullscreen mode

Side note

For those with Copilot for Visual Studio, there is a new feature known as Tell me more (but changed to Describe with Copilot) which makes an attempt at describing the method but is lacking the human touch. Do not become fully dependent on this feature.

In the image below, at top the extension method does not have a summary while the bottom image does have a summary.



/// <summary>
/// Returns the Major component of <see cref="Version"/>
/// </summary>
/// <param name="sender"></param>
/// <returns></returns>
public static int Major(this FormattableString sender)
    => Convert.ToInt32(sender.GetArgument(0));


Enter fullscreen mode Exit fullscreen mode

Image for above

EF Core queries for unmapped types

Learn using FormattableString to work with SQL statements rather than performing data operations against models. For a complete description, see the following article.

The reason for this example is to display US holidays for a specific year and the caller passes in the year.

The SQL SELECT statement was created in SSMS (SQL-Server Management Studio) and place into a raw string literal and placed into a method which returns a FormattableString.



internal class SqlStatements
{

    /// <summary>
    /// Retrieves holidays for a given year.
    /// </summary>
    /// <param name="year">The year for which to retrieve holidays.</param>
    /// <returns>A <see cref="FormattableString"/> representing the SQL query to retrieve holidays for EF Core.</returns>
    public static FormattableString GetHolidays(int year) => 
        $"""
         SELECT CalendarDate,
               CalendarDateDescription AS [Description],
               CalendarMonth,
               DATENAME(MONTH, DATEADD(MONTH, CalendarMonth, -1)) AS [Month],
               CalendarDay AS [Day],
               DayOfWeekName,
               IIF(BusinessDay = 0, 'No', 'Yes') AS BusinessDay,
               IIF(Weekday = 0, 'No', 'Yes') AS [Weekday]
          FROM DateTimeDatabase.dbo.Calendar
         WHERE CalendarYear = {year}
           AND Holiday      = 1;
         """;
}


Enter fullscreen mode Exit fullscreen mode

Here SqlQuery indicates a, in this case a list of Holiday will be return by passing in the SQL statement above. Note SqlQuery parameterizes any value passed in as in year.



internal partial class Program
{
    static async Task Main(string[] args)
    {
        await Setup();

        await using var context = new Context();

        int year = DateTime.Now.Year;

        var currentYear = await Holidays(year);

        AnsiConsole.MarkupLine(ObjectDumper.Dump(currentYear).HolidayColors());

        ExitPrompt();
    }

    static async Task<IReadOnlyList<Holiday>> Holidays(int year)
    {
        await using var context = new Context();
        return [.. context.Database.SqlQuery<Holiday>(SqlStatements.GetHolidays(year))];
    }
}  


Enter fullscreen mode Exit fullscreen mode

Updating a FormattableString

Once a FormattableString has been created, there may be a need to change one or more of the arguments.

This is done by index into the arguments then assign the new value.

Example, a new FormattableString is created using properties of a Person model/class. Afterwards, use a language extension method to index to the first name item and change it.

First an enum for easy reading.



public enum PropertyItem
{
    Id = 0,
    FirstName = 1,
    LastName = 2,
    BirthDate = 3
}


Enter fullscreen mode Exit fullscreen mode

Code to change the first name.



public static void UpdatePerson()
{
    Person person = new()
    {
        Id = 1,
        FirstName = "Karen",
        LastName = "Payne",
        BirthDate = new DateOnly(1956, 9, 24)
    };

    FormattableString format = FormattableStringFactory.Create("Id: {0} First {1} Last {2} Birth {3}",
        person.Id, person.FirstName, person.LastName, person.BirthDate);

    format.UpdateFirstName("Anne");

}


Enter fullscreen mode Exit fullscreen mode

If needed, see if first name is Karen.



if (format.FirstName() == "Karen")
{
    format.UpdateFirstName("Anne");
}


Enter fullscreen mode Exit fullscreen mode

Here are the language extension methods for working with the Person model/class.



public static class FormattableExtensions
{
    public static int Id(this FormattableString sender)
        => Convert.ToInt32(sender.GetArguments()[(int)PropertyItem.Id].ToString());

    public static string FirstName(this FormattableString sender)
        => (string)sender.GetArguments()[(int)PropertyItem.FirstName];

    public static string LastName(this FormattableString sender)
        => (string)sender.GetArguments()[(int)PropertyItem.LastName];

    public static DateOnly BirthDate(this FormattableString sender)
        => (DateOnly)sender.GetArguments()[(int)PropertyItem.BirthDate]!;

    public static void UpdateFirstName(this FormattableString sender, string value)
    {
        sender.GetArguments()[(int)PropertyItem.FirstName] = value;
    }
    public static void UpdateLastName(this FormattableString sender, string value)
    {
        sender.GetArguments()[(int)PropertyItem.LastName] = value;
    }

    public static void UpdateBirthDate(this FormattableString sender, DateOnly value)
    {
        sender.GetArguments()[(int)PropertyItem.BirthDate] = value;
    }
}


Enter fullscreen mode Exit fullscreen mode

Do the same for your model/classes.

Creating templates

FormattableString can be used for creating simple templates like for sending an email. In the following example, EmailMessages.WelcomeMessage might represent a string read from a database table then used to create a FormattableString populated using an instance of a Person class an a instance of a Manager class.



public class EmailMessages
{
    /// <summary>
    /// Gets the welcome message for new team members.
    /// </summary>
    public static string WelcomeMessage
        => "We're thrilled to have you join our team. As a member of our company, " +
           "you'll be part of a dynamic and collaborative environment dedicated to [company mission]. " +
           "Together, we'll achieve great things and contribute to [company goals]. We're excited to " +
           "see what you'll bring to the table and look forward to working with you.";
}

public interface IEmailService
{
    string SendEmail(Person person, Manager manager);
}


public class EmailService : IEmailService
{
    public string SendEmail(Person person, Manager manager) =>
        FormattableStringFactory.Create(
                """
                Hello {0} {1},

                {2}

                {3} {4}
                """,
                person.FirstName, person.LastName, 
                EmailMessages.WelcomeMessage, 
                manager.FirstName, manager.LastName)
            .ToString();
}


Enter fullscreen mode Exit fullscreen mode

Below the message is displayed to the console, in a real application the message would be feed to MailKit or other email library.



internal partial class Program
{
    static async Task Main(string[] args)
    {
        var service = await GetEmailService();

        Person person = new() { Id = 1, FirstName = "Karen", LastName = "Payne" };
        Manager manager = new() { Id = 2, FirstName = "Sam", LastName = "Smith" };
        Console.WriteLine(service.SendEmail(person, manager));
    }
}


Enter fullscreen mode Exit fullscreen mode

For more advance templating check out StringTokenFormatter NuGet package.

Summary

FormattableString has been presented which can be helpful in specific situations and with creating SQL statements for EF Core. Take time to read Microsoft documentation and study the code rather than blindly using FormattableString.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .