Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,64 +1,67 @@
---
title: Explore C# string interpolation handlers
description: This advanced tutorial shows how you can write a custom string interpolation handler that hooks into the runtime processing of an interpolated string.
ms.date: 11/22/2024
ms.date: 02/05/2026
---
# Tutorial: Write a custom string interpolation handler

In this tutorial, you learn how to:

> [!div class="checklist"]
>
> - Implement the string interpolation handler pattern
> - Implement the string interpolation handler pattern.
> - Interact with the receiver in a string interpolation operation.
> - Add arguments to the string interpolation handler
> - Understand the new library features for string interpolation
> - Add arguments to the string interpolation handler.
> - Understand the new library features for string interpolation.

## Prerequisites

You need to set up your machine to run .NET. The C# compiler is available with [Visual Studio 2022](https://visualstudio.microsoft.com/downloads/) or the [.NET SDK](https://dotnet.microsoft.com/download).
Set up your machine to run .NET. The C# compiler is available through [Visual Studio](https://visualstudio.microsoft.com/downloads/) or the [.NET SDK](https://dotnet.microsoft.com/download).

This tutorial assumes you're familiar with C# and .NET, including either Visual Studio or the .NET CLI.
This tutorial assumes you're familiar with C# and .NET, including either Visual Studio or Visual Studio Code and the C# DevKit.

You can write a custom [*interpolated string handler*](#implement-the-handler-pattern). An interpolated string handler is a type that processes the placeholder expression in an interpolated string. Without a custom handler, placeholders are processed similar to <xref:System.String.Format%2A?displayProperty=nameWithType>. Each placeholder is formatted as text, and then the components are concatenated to form the resulting string.
You can write a custom [*interpolated string handler*](#implement-the-handler-pattern). An interpolated string handler is a type that processes the placeholder expression in an interpolated string. Without a custom handler, the system processes placeholders similar to <xref:System.String.Format%2A?displayProperty=nameWithType>. Each placeholder is formatted as text, and then the components are concatenated to form the resulting string.

You can write a handler for any scenario where you use information about the resulting string. Will it be used? What constraints are on the format? Some examples include:
You can write a handler for any scenario where you use information about the resulting string. Consider questions like: Is it used? What constraints are on the format? Some examples include:

- You might require none of the resulting strings are greater than some limit, such as 80 characters. You can process the interpolated strings to fill a fixed-length buffer, and stop processing once that buffer length is reached.
- You might have a tabular format, and each placeholder must have a fixed length. A custom handler can enforce that, rather than forcing all client code to conform.
- You might have a tabular format, and each placeholder must have a fixed length. A custom handler can enforce that constraint, rather than forcing all client code to conform.

In this tutorial, you create a string interpolation handler for one of the core performance scenarios: logging libraries. Depending on the configured log level, the work to construct a log message isn't needed. If logging is off, the work to construct a string from an interpolated string expression isn't needed. The message is never printed, so any string concatenation can be skipped. In addition, any expressions used in the placeholders, including generating stack traces, doesn't need to be done.
In this tutorial, you create a string interpolation handler for one of the core performance scenarios: logging libraries. Depending on the configured log level, the work to construct a log message isn't needed. If logging is off, the work to construct a string from an interpolated string expression isn't needed. The message is never printed, so any string concatenation can be skipped. In addition, any expressions used in the placeholders, including generating stack traces, don't need to be done.

An interpolated string handler can determine if the formatted string will be used, and only perform the necessary work if needed.
An interpolated string handler can determine if the formatted string is used, and only perform the necessary work if needed.

## Initial implementation

Let's start from a basic `Logger` class that supports different levels:
Start with a basic `Logger` class that supports different levels:

:::code language="csharp" source="./snippets/interpolated-string-handler/Logger-v1.cs" id="InitialLogger":::

This `Logger` supports six different levels. When a message doesn't pass the log level filter, there's no output. The public API for the logger accepts a (fully formatted) string as the message. All the work to create the string has already been done.
This `Logger` supports six different levels. When a message doesn't pass the log level filter, the logger produces no output. The public API for the logger accepts a fully formatted string as the message. The caller does all the work to create the string.

## Implement the handler pattern

This step is to build an *interpolated string handler* that recreates the current behavior. An interpolated string handler is a type that must have the following characteristics:
In this step, you build an *interpolated string handler* that recreates the current behavior. An interpolated string handler is a type that must have the following characteristics:

- The <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute?displayProperty=fullName> applied to the type.
- A constructor that has two `int` parameters, `literalLength` and `formattedCount`. (More parameters are allowed).
- A public `AppendLiteral` method with the signature: `public void AppendLiteral(string s)`.
- A generic public `AppendFormatted` method with the signature: `public void AppendFormatted<T>(T t)`.

Internally, the builder creates the formatted string, and provides a member for a client to retrieve that string. The following code shows a `LogInterpolatedStringHandler` type that meets these requirements:
Internally, the builder creates the formatted string and provides a member for a client to retrieve that string. The following code shows a `LogInterpolatedStringHandler` type that meets these requirements:

:::code language="csharp" source="./snippets/interpolated-string-handler/Logger-v2.cs" id="CoreInterpolatedStringHandler":::

> [!NOTE]
> When the interpolated string expression is a compile-time constant (that is, it has no placeholders), the compiler uses the target type `string` instead of invoking a custom interpolated string handler. This behavior means constant interpolated strings bypass custom handlers entirely.

You can now add an overload to `LogMessage` in the `Logger` class to try your new interpolated string handler:

:::code language="csharp" source="./snippets/interpolated-string-handler/Logger-v2.cs" id="LogMessageOverload":::

You don't need to remove the original `LogMessage` method, the compiler prefers a method with an interpolated handler parameter over a method with a `string` parameter when the argument is an interpolated string expression.
You don't need to remove the original `LogMessage` method. When the argument is an interpolated string expression, the compiler prefers a method with an interpolated handler parameter over a method with a `string` parameter.

You can verify that the new handler is invoked using the following code as the main program:
You can verify that the new handler is invoked by using the following code as the main program:

:::code language="csharp" source="./snippets/interpolated-string-handler/Version_2_Examples.cs" id="UseInterpolatedHandler":::

Expand Down Expand Up @@ -93,27 +96,30 @@ Finally, notice that the last warning doesn't invoke the interpolated string han

> [!IMPORTANT]
>
> Use `ref struct` for interpolated string handlers only if absolutely necessary. Using `ref struct` will have limitations as they must be stored on the stack. For example, they will not work if an interpolated string hole contains an `await` expression because the compiler will need to store the handler in the compiler-generated `IAsyncStateMachine` implementation.
> Use `ref struct` for interpolated string handlers only if absolutely necessary. `ref struct` types have limitations as they must be stored on the stack. For example, they don't work if an interpolated string hole contains an `await` expression because the compiler needs to store the handler in the compiler-generated `IAsyncStateMachine` implementation.

## Add more capabilities to the handler

The preceding version of the interpolated string handler implements the pattern. To avoid processing every placeholder expression, you need more information in the handler. In this section, you improve your handler so that it does less work when the constructed string isn't written to the log. You use <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute?displayProperty=fullName> to specify a mapping between parameters to a public API and parameters to a handler's constructor. That provides the handler with the information needed to determine if the interpolated string should be evaluated.
The preceding version of the interpolated string handler implements the pattern. To avoid processing every placeholder expression, you need more information in the handler. In this section, you improve your handler so that it does less work when the constructed string isn't written to the log. You use <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute?displayProperty=fullName> to specify a mapping between parameters to a public API and parameters to a handler's constructor. That mapping provides the handler with the information needed to determine if the interpolated string should be evaluated.

Let's start with changes to the Handler. First, add a field to track if the handler is enabled. Add two parameters to the constructor: one to specify the log level for this message, and the other a reference to the log object:
Start with changes to the handler. First, add a field to track if the handler is enabled. Add two parameters to the constructor: one to specify the log level for this message, and the other a reference to the log object:

:::code language="csharp" source="./snippets/interpolated-string-handler/logger-v3.cs" id="AddEnabledFlag":::

Next, use the field so that your handler only appends literals or formatted objects when the final string will be used:
Next, use the field so that your handler only appends literals or formatted objects when the final string is used:

:::code language="csharp" source="./snippets/interpolated-string-handler/logger-v3.cs" id="AppendWhenEnabled":::

Next, you need to update the `LogMessage` declaration so that the compiler passes the additional parameters to the handler's constructor. That's handled using the <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute?displayProperty=nameWithType> on the handler argument:
Next, update the `LogMessage` declaration so that the compiler passes the additional parameters to the handler's constructor. Handle this step by using the <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute?displayProperty=nameWithType> on the handler argument:

:::code language="csharp" source="./snippets/interpolated-string-handler/logger-v3.cs" id="ArgumentsToHandlerConstructor":::

This attribute specifies the list of arguments to `LogMessage` that map to the parameters that follow the required `literalLength` and `formattedCount` parameters. The empty string (""), specifies the receiver. The compiler substitutes the value of the `Logger` object represented by `this` for the next argument to the handler's constructor. The compiler substitutes the value of `level` for the following argument. You can provide any number of arguments for any handler you write. The arguments that you add are string arguments.
This attribute specifies the list of arguments to `LogMessage` that map to the parameters that follow the required `literalLength` and `formattedCount` parameters. The empty string (`""`), specifies the receiver. The compiler substitutes the value of the `Logger` object represented by `this` for the next argument to the handler's constructor. The compiler substitutes the value of `level` for the following argument. You can provide any number of arguments for any handler you write. The arguments that you add are string arguments.

> [!NOTE]
> If the <xref:System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute> constructor argument list is empty, the behavior is the same as if the attribute was omitted entirely.

You can run this version using the same test code. This time, you see the following results:
You can run this version by using the same test code. This time, you see the following results:

```powershell
literal length: 65, formattedCount: 1
Expand All @@ -131,9 +137,9 @@ Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be p
Warning Level. This warning is a string, not an interpolated string expression.
```

You can see that the `AppendLiteral` and `AppendFormat` methods are being called, but they aren't doing any work. The handler determined that the final string isn't needed, so the handler doesn't build it. There are still a couple of improvements to make.
You see that the `AppendLiteral` and `AppendFormat` methods are called, but they aren't doing any work. The handler determined that the final string isn't needed, so the handler doesn't build it. There are still a couple of improvements to make.

First, you can add an overload of `AppendFormatted` that constrains the argument to a type that implements <xref:System.IFormattable?displayProperty=nameWithType>. This overload enables callers to add format strings in the placeholders. While making this change, let's also change the return type of the other `AppendFormatted` and `AppendLiteral` methods, from `void` to `bool` (if any of these methods have different return types, then you get a compilation error). That change enables *short circuiting*. The methods return `false` to indicate that processing of the interpolated string expression should be stopped. Returning `true` indicates that it should continue. In this example, you're using it to stop processing when the resulting string isn't needed. Short circuiting supports more fine-grained actions. You could stop processing the expression once it reaches a certain length, to support fixed-length buffers. Or some condition could indicate remaining elements aren't needed.
First, you can add an overload of `AppendFormatted` that constrains the argument to a type that implements <xref:System.IFormattable?displayProperty=nameWithType>. This overload enables callers to add format strings in the placeholders. While making this change, also change the return type of the other `AppendFormatted` and `AppendLiteral` methods, from `void` to `bool`. If any of these methods have different return types, you get a compilation error. That change enables *short circuiting*. The methods return `false` to indicate that processing of the interpolated string expression should be stopped. Returning `true` indicates that it should continue. In this example, you're using it to stop processing when the resulting string isn't needed. Short circuiting supports more fine-grained actions. You could stop processing the expression once it reaches a certain length, to support fixed-length buffers. Or some condition could indicate remaining elements aren't needed.

:::code language="csharp" source="./snippets/interpolated-string-handler/logger-v4.cs" id="AppendIFormattable":::

Expand Down Expand Up @@ -195,24 +201,24 @@ Error Level. CurrentTime: 12:19 PM. This is an error. It will be printed.
Warning Level. This warning is a string, not an interpolated string expression.
```

The only output when `LogLevel.Trace` was specified is the output from the constructor. The handler indicated that it's not enabled, so none of the `Append` methods were invoked.
The only output when `LogLevel.Trace` was specified is the output from the constructor. The handler indicated that it's not enabled, so none of the `Append` methods are invoked.

This example illustrates an important point for interpolated string handlers, especially when logging libraries are used. Any side-effects in the placeholders might not occur. Add the following code to your main program and see this behavior in action:

:::code language="csharp" source="./snippets/interpolated-string-handler/Version_4_Examples.cs" id="TestSideEffects":::

You can see the `index` variable is incremented five times each iteration of the loop. Because the placeholders are evaluated only for `Critical`, `Error` and `Warning` levels, not for `Information` and `Trace`, the final value of `index` doesn't match the expectation:
You can see the `index` variable is incremented each iteration of the loop. Because the placeholders are evaluated only for `Critical`, `Error`, and `Warning` levels, not for `Information`, and `Trace`, the final value of `index` doesn't match the expectation:

```powershell
Critical
Critical: Increment index a few times 0, 1, 2, 3, 4
Critical: Increment index 0
Error
Error: Increment index a few times 5, 6, 7, 8, 9
Error: Increment index 1
Warning
Warning: Increment index a few times 10, 11, 12, 13, 14
Warning: Increment index 2
Information
Trace
Value of index 15, value of numberOfIncrements: 25
Value of index 3, value of numberOfIncrements: 5
```

Interpolated string handlers provide greater control over how an interpolated string expression is converted to a string. The .NET runtime team used this feature to improve performance in several areas. You can make use of the same capability in your own libraries. To explore further, look at the <xref:System.Runtime.CompilerServices.DefaultInterpolatedStringHandler?displayProperty=fullName>. It provides a more complete implementation than you built here. You see many more overloads that are possible for the `Append` methods.
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void AppendFormatted<T>(T t)
Console.WriteLine($"\tAppended the formatted object");
}

internal string GetFormattedText() => builder.ToString();
public override string ToString() => builder.ToString();
}
// </CoreInterpolatedStringHandler>

Expand Down Expand Up @@ -61,7 +61,7 @@ public void LogMessage(LogLevel level, string msg)
public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
if (EnabledLevel < level) return;
Console.WriteLine(builder.GetFormattedText());
Console.WriteLine(builder.ToString());
}
// </LogMessageOverload>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public static void Example()
for (var level = LogLevel.Critical; level <= LogLevel.Trace; level++)
{
Console.WriteLine(level);
logger.LogMessage(level, $"{level}: Increment index a few times {index++}, {index++}, {index++}, {index++}, {index++}");
numberOfIncrements += 5;
logger.LogMessage(level, $"{level}: Increment index {index++}");
numberOfIncrements++;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>interpolated_string_handler</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
Loading