Alexito's World

A world of coding 💻, by Alejandro Martinez

Solving the String problem with Swift 5

The String problem is something that I've explored before, in my 2015 post Solving the Strings Problem in Swift. The solution back then was far from ideal as Swift was missing a powerful String interpolation system.

Swift 5 has revamped the Strings and it's time to take another look at the same problem. I'm not gonna follow the exact same steps as I did before, instead I'm gonna try to make a more Swifty solution.

The problem

Let's quickly recap what the problem is.

The String type is a type that can represent absolutely everything. It's usage to display text is justified, but programmers are used to reach to it for every single problem. Things like URLs, paths, email, XML, etc… all should have their own types.

The issue with relying so much in Strings is that we lose all type safety. The compiler won't stop you from passing a string representing a email to a function that expects that string to be a URL.

A general solution for this problem accepts that strings have different meaning and so they should be represented with their own types.

A nice solution needs to be easy to adopt and safe. The safety will come from the type system but making nice to use it's also important.

Define a Language

Let's start by defining a Language protocol. The different kinds of string will conform to it.

protocol Language {
    /// The name of the language
    var name: String { get }
    
    /// Get the underlaying well constructed String
    var value: String { get }
    
    /// Appends a fragment of the language, safe
    mutating func appendFragment(_ fragment: String)
    
    /// Appends an arbitrary string, unsafe
    mutating func appendText(_ text: String)
}

The protocol defines four things that any safe string type will need.

The name of the language: this is really not needed and mainly added here for debugging purposes. In a more dynamic environment one could imagine having to take decisions based on this, but I haven't found a reason for it. We can always add a default implementation using the name of the type so it's not a huge burden.

We will need a way to extract a String from a language instance. In our program we will use the specific type as much as we can to keep the type safety, but at some point that safe string will have to be displayed on the screen or sent to another system. Only at that point is when we will escape the safety of our types.

The last two methods are what makes the type flexible but safe. We define a fragment as a correct and safe string, already in the specific language of the type. But we also allow to add unsafe text, an arbitrary string, that the type will have to make sure is processed to conform to the specifications of the language.

A safe URL as an example

Let's start by defining a SafeURL type. Its purpose would be to keep the URL safe and properly escaped.

Conforming the type to the protocol is easy:

struct SafeURL: Language {
  var url: String
  ...
  mutating func appendFragment(_ fragment: String) {
      url.append(fragment)
  }

  mutating func appendText(_ text: String) {
      url.append(urlEncoded(string: text))
  }
}

As you can see the interesting part is the appendText function. It's where the type needs to make sure the incoming string is processed properly. In this case it needs to make sure is encoded with the proper characters for a URL. For example a space should become a %20.

Basic usage

Now that we have our first Langauge type SafeURL we can start using it to create a URL from unsafe strings.

var url = SafeURL()
url.appendFragment("https://example.com/")
url.appendText("this should be escaped")
url.value // https://example.com/this%20should%20be%20escaped

The fragments are treated as safe an untouched, but the arbitrary text is correctly escaped to not break the URL.

Now the type system protects us from using this as a String. We can't add a string directly with the String API:

url.appending("wrong!")
// Value of type 'SafeURL' has no member 'appending'; did you mean 'appendText'?

And we can't pass it to a function that expects a String.

expectString(url)
// Cannot convert value of type 'SafeURL' to expected argument type 'String'

ExpressibleByStringLiteral

Now we have a safer type but using it is more cumbersome than using a String. Let's try to fix that.

First we can make the type conform to ExpressibleByStringLiteral so we can initialise it with a literal.

extension SafeURL: ExpressibleByStringLiteral {
    init(stringLiteral value: String) {
        self.init()
        self.appendText(value)
    }
}

This is an important part of the system to get right. We need to make sure that we're treating this string literal as unsafe.

This initialiser is used in different scenarios. The most obvious one is when declaring a variable of the type:

let url: SafeURL = ""

In this we could argue that the developer is writing the literal string so it should make sure is properly escaped.

But the problem is that with our type conforming to this protocol now we lost some safety because now Swift will automatically convert a string to our type when passing it as an argument to functions. And in those scenarios asking a developer to take care of it is not that nice anymore.

expectSafeURL("https://example.com/this should be escaped")

Now we can create a SafeURL from a string literal but the issue is that it will always escape it even if we've already done it ourselves.

let url: SafeURL = "https://example.com/this%20should%20be%20escaped"
url.value // "http%3A%2F%2Fexample.com%2Fthis%2520should%2520be%2520escaped

We should provide the developer a way to create our type directly with an already safe string. We can easily do that with an initialiser in an extension.

extension SafeURL {
    init(safe: String) {
        self.init()
        self.appendFragment(safe)
    }
}

let url = SafeURL(safe: "https://example.com/this%20should%20be%20escaped")
url.value // https://example.com/this%20should%20be%20escaped

Now we can create our type from string literals that keeps it safe by default but we also have a way to skip the escaping if we know the string is already correct.

Improving the API

This is nice but we haven't improved much the situation since the solution on 2015. We still need to escape the characters ourselves if we want to use nice APIs, something that will rarely happen.

Check the next article on this series to see how Swift 5 new String interpolation system will help us.

If you liked this article please consider supporting me