The evolution of .NET has provided developers with different avenues to experiment with ideas in their codebase. Sometimes those are good ideas, and sometimes they’re just fun ideas. For example, today’s blog post is inspired by a Twitter thread and musings on what it might take to format an array of elements using string interpolation in C#.

What is an InterpolatedStringHandler?

If you’ve used C# in the last several years, you’re likely familiar with the $ operator that developers place alongside string instances. String interpolation allows you to inline variables.

var name = "Khalid";
var hello = $"Hello, {name}";

String interpolation is a straightforward concept used by almost everyone working with C# and F# today. But did you know you can write your own InterpolatedStringHandler?

Since C# 10, you can create your own interpolated string handlers using the InterpolatedStringHandlerAttribute and a struct with conventional public methods. There’s a good tutorial on the official Microsoft documentation here.

In practice, a custom string interpolation handler allows you to take what might look like a simple string with interpolated values and customize the final string output with various formats, translations, and almost anything you can imagine.

The String Interpolation Challenge

This dive into string interpolation started with a problem. When you string interpolate an array of objects, you get the type name in your output.

var numbers = Enumerable.Range(0, 10).ToArray();
// output: System.Int32[]
Console.WriteLine($"{numbers}");

While technically accurate, wouldn’t it be better if C# could determine that an element is enumerable and print out each value? Not only outputting arrays properly, but what if you could add placeholders?

What if we could do the following?

Numbers.Write($"Call {numbers:(###) ###-####} For A Good Time!");
Numbers.Write('x', $"Call {numbers:(xxx) xxx-xxxx} For A Good Time!");
Numbers.Range(numbers, $"Call ({0..3}) {3..6}-{6..9} For A Good Time!");

After executing the code, we’d expect our numbers to display in the following formats.

Call (012) 345-6789 For A Good Time!
Call (012) 345-6789 For A Good Time!
Call (012) 345-678 For A Good Time!

Well, lucky for you, Kristian Hellang, and I experimented with string interpolation handlers, and we’ve made the above code work!

using System.Collections;
using System.Runtime.CompilerServices;
using System.Text;

var numbers = Enumerable.Range(0, 10).ToArray();
Numbers.Write($"Call {numbers:(###) ###-####} For A Good Time!");
Numbers.Write('x', $"Call {numbers:(xxx) xxx-xxxx} For A Good Time!");
Numbers.Range(numbers, $"Call ({0..3}) {3..6}-{6..9} For A Good Time!");

public static class Numbers
{
    public static void Write(PlaceholderInterpolatedStringHandler builder)
    {
        Write('#', builder);
    }

    public static void Write(
        char placeholder, 
        [InterpolatedStringHandlerArgument("placeholder")] PlaceholderInterpolatedStringHandler builder
    )
    {
        Console.WriteLine(builder.GetFormattedText());
    }

    public static void Range<T>(
        T[] args,
        [InterpolatedStringHandlerArgument("args")]
        RangeInterpolatedStringHandler<T> handler)
    {
        Console.WriteLine(handler.ToString());
    }
}


/// <summary>
/// Credit to Khalid @buhakmeh
/// </summary>
[InterpolatedStringHandler]
public readonly struct PlaceholderInterpolatedStringHandler
{
    private char Placeholder { get; }
    private StringBuilder Builder { get; }

    public PlaceholderInterpolatedStringHandler(int literalLength, int formattedCount, char placeholder = '#')
        => (Placeholder, Builder) = (placeholder, new StringBuilder());

    public void AppendLiteral(string s) => Builder.Append(s);
    internal string GetFormattedText() => Builder.ToString();
    
    public void AppendFormatted(IEnumerable t, string format)
    {
        var enumerator = t.GetEnumerator();
        foreach (var c in format)
        {
            if (c == Placeholder && enumerator.MoveNext())
                Builder.Append(enumerator.Current);
            else
                Builder.Append(c);
        }
    }
}

/// <summary>
/// Credit to Kristian Hellang (@khellang)
/// </summary>
/// <typeparam name="T"></typeparam>
[InterpolatedStringHandler]
public readonly struct RangeInterpolatedStringHandler<T>
{
    public RangeInterpolatedStringHandler(int literalLength, int formattedCount, T[] args)
        => (Args, Builder) = (args, new StringBuilder());
    
    private StringBuilder Builder { get; }
    private T[] Args { get; }

    public void AppendFormatted(Range range)
        => Builder.Append(string.Concat(Args[range]));

    public void AppendLiteral(string value)
        => Builder.Append(value);

    public string ToString() => 
        Builder.ToString();
}

What I Learned About String Interpolation Handlers

I think a few interesting aspects of custom string interpolation handlers are worth mentioning.

  1. A custom interpolated string handler used a struct type to keep memory allocations to a minimum.
  2. Just because the handler is a struct doesn’t mean you can’t do bad things in your Append methods. Be very careful.
  3. The handler’s constructor takes the literalLength and formattedCount, giving you information to allocate a string builder to the exact size if possible.
  4. Interpolation arguments must come before the custom string interpolation argument in a method. Look at the implementations above if that sounds confusing.
  5. Append methods take concrete types and interfaces, so you don’t have to box and unbox values.

Conclusion

String interpolation is incredible, and you have an opportunity to make your codebase much easier to work with when utilizing them. They’re also entertaining to play around with and imagine “what if” scenarios. If you’ve used custom string interpolation handlers in your applications, I’d love to hear about it. I’d also like to thank Kristian Hellang for the idea and his implementation of my original idea. It’s fun when we do things as a community. Cheers :)