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
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!