United Kingdom: +44 (0)208 088 8978

Flexible String Interpolation: The Silly Oversight of a Missing Compiler Error

This post dives into the quirks of string interpolation and how a simple oversight led to an unexpected glitch in our CI pipeline

We're hiring Software Developers

Click here to find out more

String interpolation is one of these features that we keep banging on about; it's just such a convenient way of building up strings! For those unfamiliar, string interpolation lets you embed expressions in a string, so you can build up a string with variables and expressions in a more readable way.

Here's a little example:

module StringInterpolation =

    let message receiver =
        $"Hello, {receiver}!"

module NoStringInterpolation =

    let message receiver =
        "Hello, " + receiver + "!"

It's a very simple syntax, and very flexible too! If you pass it a record, it will pretty-print the record for you:

type Record = { Name: string; Age: int }
let myRecord = { Name = "John"; Age = 30 }
printfn $"my record is: {myRecord}"
// my record is: { Name = "John"
// Age = 30 }

but just this level of flexibility can be a double-edged sword. The other day, I made a value in some code lazy, using the lazy keyword; the change was something like this:

 let customer = "acme"
 let project = "factory"

-let environment = "UAT"
+let environment = lazy "UAT"

 let containerAppName = $"acme-{project}-{environment}"

The compiler was happy, I was happy. The change was rather trivial and only changed how some config was generated. So, without too much testing, I merged to master and ran a CI pipeline... to be greeted by an error:

Invalid ContainerApp name 'acme-factory-Value is not creat (trimmed)'

String interpolation had done its job: it nicely formatted a description of the lazy value; not really what I wanted though! Any other F# function would have called me out at this point, because it would have expected a string, but received a Lazy. Not string interpolation though, it's a bit too flexible for that!

Making string interpolation typed

As you've seen, this loose typing can lead to weird problems that might not be immediately obvious. Fortunately, we can make string interpolation typed using format specifiers; for example, we can use %s to enforce that the next value is a string. You can find a full list of format specifiers, including some very flexible options that will still take anything you feed it, on the F# docs. Our snippet will get an error now:

let customer = "acme"
let project = "factory"
let environment = lazy "UAT"
let containerAppName = $"acme-%s{project}-%s{environment}" // This expression was expected to have type    'string'    but here has type    'Lazy<string>'

Great! to fix the error, simply unpack the lazy value:

let containerAppName = $"acme-%s{project}-%s{environment.Value}"

Forcing typed string interpolation

To make sure that we can't fall for this trap again, we can let the compiler tell us to add type specifiers to interpolated strings, by opting in to warning 3597; these warnings do not show up in your editor, but are emitted by the compiler, for example on build or on when running dotnet run. If you really want to make sure you don't miss any type annotations, you can even turn the warning into an error:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <!-- enables warnings for untyped string interpolation -->
        <WarnOn>3579</WarnOn>
        <!-- enables handling untyped string interpolation as an error -->
        <WarningsAsErrors>3579</WarningsAsErrors>
    </PropertyGroup>

    <ItemGroup>
        <Compile Include="Program.fs"/>
    </ItemGroup>

</Project>

Untyped interpolated strings now give us an error:

Error FS3579: Interpolated string contains untyped identifiers. Adding typed format specifiers is recommended.

Conclusion

String interpolation is a powerful feature, but it can be a bit too flexible for its own good. By using format specifiers, we can make sure that we don't accidentally pass in the wrong type. Adding these type specifiers requires a bit of discipline, but warning 3579 can be a helpful tool to enforce this!