Making String safe with interpolation
In the previous article we made a safe string type that was safe to use but with a cumbersome API. In this part we will use new Swift 5 interpolation system to make it really nice to use.
The goal is to provide a way to use nice string literals but keeping the safety of having custom types for safe strings, allowing us to combine safe and unsafe strings with ease.
Up until now there was no nice solution in Swift as the interpolation system was quite limited and discarded any types. But Swift 5 revamps the interpolation system giving us a chance to keep the type safety that we love from the language. This opens a bast array of possibilities for designing new APIs in Swift.
The new system introduces a protocol StringInterpolationProtocol
that allows us to define a type that will gather the information during the interpolation. Later in the process this intermediate type is given to the final type that implements ExpressibleByStringInterpolation
.
It’s a nice design because is not far from what we had before, the difference is that before string interpolation methods just got Strings so we lost any type information. Now we can keep the type information thanks to specific method overloads for different types.
But if you check the protocol expecting to find those methods you will be surprised:
public protocol StringInterpolationProtocol {
init(literalCapacity: Int, interpolationCount: Int)
mutating func appendLiteral(_ literal: Self.StringLiteralType)
}
It only has 2 methods! Where are all those methods that will help us keep the type information?
Well, the trick here is that the protocol has informal requirements. We can write methods with the form
mutating func appendInterpolation(label: Type)
and the compiler will recognise them and use them as appropriate with the default overloading system. Furthermore it will offer the label
to the string interpolation system.
This compiler magic serves as a workaround for a limitation of the language in this kind of designs. There is no way to define a protocol with this flexibility. A similar issue was found with the Dynamic Member Lookup feature.
This little compiler magic allows us to be able to write as many special appendInterpolation
methods as we want, with different labels and types, and thus offer a nice API for interpolation.
Conforming to StringInterpolationProtocol
We can extend our type to conform to StringInterpolationProtocol
:
extension SafeURL: StringInterpolationProtocol {
There are many ways of implementing this interpolation system. Usually you can have an intermediate type (conforming to StringInterpolationProtocol) that gathers the information and the final type that uses it (conforming to ExpressibleByStringInterpolation).
I’m just gonna use the same type for both, just for the simplicity of the example. Furthermore it will make it easier to implement because the API of the Language protocol already matches quite well the StringInterpolation API.
The first method we need to implement is the initializer.
init(literalCapacity: Int, interpolationCount: Int) {
self.init()
}
This initializer gives a chance to the conforming type to preallocate the memory needed. It’s a nice way of improving performance although usually you don’t have all the information needed to allocate exactly the amount of memory you need.
For example if we wanted to create a string literal with "https://example.com/\("this should be escaped")"
in the initialiser we would receive a literal capacity of 19 and an interpolation count of 1. The literal capacity is fine but we still have no clue of how much data we will need for the interpolation. In any case is a nice design that can help improve the performance of string code.
mutating func appendLiteral(_ literal: String) {
self.appendFragment(literal)
}
This method is called for each literal String segment. Just as before we get these literal segments as String, but the nice thing now is that this method will only be called for the segments that are Strings. We can add other methods for specific types or labels! No more lost of types at runtime.
Because of this we can assume the literals to be safe. If the developer is using interpolation it will be able to use other labels to specify unsafe code, so we can make the default case nicer to use.
Now is time to use the power of the new system. Let’s add a couple of appendInterpolation
method overloads to accommodate for different situations.
mutating func appendInterpolation(unsafe text: String) {
self.appendText(text)
}
This method will allow us to support the interpolation of unsafe strings. As mentioned before, this is not part of the formal protocol, but the compiler knows about it.
In our case we offer a label unsafe
and the type String
. This will be used on the interpolation syntax and overload resolution.
Thanks to appendLiteral
we have a way of adding safe segments, but that only works when the String is a literal. If we want to support adding a String at runtime that we know is safe we can add a new overload. Of course this is a little more dangerous, but the nice thing is that because we’re using a label it’s harder to happen by mistake.
mutating func appendInterpolation(safe text: String) {
self.appendFragment(text)
}
Conforming to ExpressibleByStringInterpolation
Finally we need to conform to ExpressibleByStringInterpolation
and make its StringInterpolation
associated type be of the type we made conform to StringInterpolationProtocol
.
As I said above, I’m using the same type for both protocols. But if you have another type, you will use the associated type to tell the system which type to use.
In our case we just need to extract the value and treat it as a safe fragment.
extension SafeURL: ExpressibleByStringInterpolation {
init(stringInterpolation: SafeURL) {
self.init()
appendFragment(stringInterpolation.value)
}
}
Enjoy
With this now we can rewrite the examples of the previous post with a nicer syntax and being safe and correct:
let url: SafeURL = "https://example.com/\(unsafe:"this should be escaped")"
url.value // https://example.com/this%20should%20be%20escaped
And to demonstrate some safety. If we had those literals as strings instead, we wouldn’t be able to use interpolation because it would be unsafe:
let base = "https://example.com"
let path = "this should be escaped"
let url: SafeURL = "\(base)/\(unsafe:path)"
// error: argument labels '(_:)' do not match any available overloads
The compiler complains that the first interpolation doesn’t match any overload. And that is correct because there is no label! Without a label we can only interpolate string literals.
But in this case we know that is safe to do, so we can use the other overload we added:
let url: SafeURL = "\(safe:base)/\(unsafe:path)"
url.value // https://example.com/this%20should%20be%20escaped
And this compiles and works as expected. Safe and nice. 👍🏻
What’s the compiler doing?
If you add some print statements on every method it is really easy to see how the compiler transforms the interpolated string into a series of calls to our methods.
The example
let url: SafeURL = "\(safe:base)/\(unsafe:path)"
is transformed into
StringInterpolationProtocol.init(literalCapacity: 1, interpolationCount: 2)
appendLiteral: ""
appendInterpolation(safe: "https://example.com")
appendLiteral: "/"
appendInterpolation(unsafe: "this should be escaped")
appendLiteral: ""
ExpressibleByStringInterpolation.init(stringInterpolation: SafeURL(name: "URL", url: "https://example.com/this%20should%20be%20escaped"))
Generalising the conformance
Now that we have a nice safe string type using the Swift 5 string interpolation we can see how nice the API can be.
But right now we would have to rewrite a bunch of code if we wanted to make more safe string types.
On the next post of the series we will do some changes do make that way easier.