GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>

Super-powered string interpolation in Swift 5.0

Strings get a massive power up in Swift 5.0.

Paul Hudson       @twostraws

String interpolation has been around since the earliest days of Swift, but in Swift 5.0 it’s getting a massive overhaul to make it faster and more powerful.

In this article I want to walk through what’s changing and how to apply it to your own code. You can also download my code for this article here.

Hacking with Swift is sponsored by Proxyman.

SPONSORED Debug 10x faster with Proxyman. Your ultimate tool to capture HTTPs requests/ responses, natively built for iPhone and macOS. You’d be surprised how much you can learn about any system by watching what it does over the network.

Try Now!

Sponsor Hacking with Swift and reach the world's largest Swift community!

The basics

We’re used to basic string interpolation like this:

let age = 38
print("You are \(age)")

We take it for granted these days, but it was a huge quality of life improvement over the syntax we had previously:

[NSString stringWithFormat:@"%ld", (long)unreadCount];

But it’s also an important performance improvement, because the alternative was to write string joining code like this:

let all = s1 + s2 + s3 + s4

Yes, it achieves the same end result, but Swift would have to add s1 to s2 to make s5, add s5 to s3 to make s6, and add s6 to s4 to make s7, before assigning that to all.

String interpolation hasn’t changed much since Swift 1.0, with the only real change coming in Swift 2.1 where we gained the ability to use string literals in interpolations, like this:

print("Hi, \(user ?? "Anonymous")")

Now, as you know Swift Evolution drives Swift forward constantly using ideas from the community. They get discussed, they develop, and they either get accepted or rejected. And this isn't just a once-a-year thing, either. In Swift 4.2 alone, a ton of features were introduced – this was no minor release!

As for Swift 5.0, it's fair to say that ABI stability is the star of the show – it's what developers are most keen to see. But there's so much more, not least raw strings, Result, and isMultiple(of:).

Well, after five years of hard service, Swift Evolution has finally come for string interpolation: in Swift 5.0 it gains new super powers that give us substantially more control over how it works.

To try it out, let’s dive into some code.

If we make a new age integer like this:

let age = 38

Then it feels pretty obvious that I can use that with string interpolation:

print("Hi, I'm \(age).")

But what if we decided we wanted to format that differently?

Using the new string interpolation system in Swift 5.0 we can extend String.StringInterpolation to add our own custom interpolations, like this:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ value: Int) {
        let formatter = NumberFormatter()
        formatter.numberStyle = .spellOut

        if let result = formatter.string(from: value as NSNumber) {
            appendLiteral(result)
        }
    }
}

Now the code will print out the integer as text: “Hi, I’m thirty-eight.”

We could use the same technique to adjust date formatting, because by default dates in strings don’t look great. Try writing this:

print("Today's date is \(Date()).")

You’ll see that Swift prints the date as something like “2019-02-21 23:30:21 +0000”. We can smarter than up with a custom date interpolation:

mutating func appendInterpolation(_ value: Date) {
    let formatter = DateFormatter()
    formatter.dateStyle = .full

    let dateString = formatter.string(from: value)
    appendLiteral(dateString)
}

That looks much better – you’ll see something like “February 21, 2019 23:30:21” instead.

This kind of customization possibility is at the heart of this new string interpolation system – we have much more control over how it works.

Note: to avoid confusing your colleagues, you probably shouldn’t override Swift’s defaults. So, name your parameters as needed to avoid confusion:

mutating func appendInterpolation(format value: Int) {

Now we call that using a format parameter name, like this:

print("Hi, I'm \(format: age).")

With that change it’s clear we’re triggering custom behavior now.

Interpolation with parameters

That small change shows how we have complete control over the way string interpolation handles parameters. You see, appendInterpolation() so that we can handle various different data types in unique ways.

For example, we could write some code to handle Twitter handles, looking specifically for the twitter parameter name like this:

mutating func appendInterpolation(twitter: String) {
    appendLiteral("<a href=\"https://twitter.com/\(twitter)\">@\(twitter)</a>")
}

Now we can use that in string interpolation:

print("You should follow me on Twitter: \(twitter: "twostraws").")

But why stop at one parameter? For our number formatting example, there’s no reason to force folks to use spell out style – we can change the method to add a second parameter:

mutating func appendInterpolation(format value: Int, using style: NumberFormatter.Style) {

We could use that inside the method rather than forcing spell out style:

mutating func appendInterpolation(format value: Int, using style: NumberFormatter.Style) {
    let formatter = NumberFormatter()
    formatter.numberStyle = style

    if let result = formatter.string(from: value as NSNumber) {
        appendLiteral(result)
    }
}

And use it in our call site:

print("Hi, I'm \(format: age, using: .spellOut).")

You can have as many of these parameters as you want, and they can be whatever you want too.

An example I like to give folks is using autoclosures with a default value, like this:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ values: [String], empty defaultValue: @autoclosure () -> String) {
        if values.count == 0 {
            appendLiteral(defaultValue())
        } else {
            appendLiteral(values.joined(separator: ", "))
        }
    }
}

let names = ["Malcolm", "Jayne", "Kaylee"]
print("Crew: \(names, empty: "No one").")

Using @autoclosure means that we can use simple values or call complex functions for the default value.

Now you’re probably thinking that we could rewrite this code without using the string interpolation system, something like this:

extension Array where Element == String {
    func formatted(empty defaultValue: @autoclosure () -> String) -> String {
        if count == 0 {
            return defaultValue()
        } else {
            return self.joined(separator: ", ")
        }
    }
}

print("Crew: \(names.formatted(empty: "No one")).")

But now we’re just cluttering our call site – we’re obviously trying to format something, because that’s the point of string interpolation. Remember the Swift style guide: omit unnecessary words.

Erica Sadun gave a really short and sweet example of how this can help clean up your code:

extension String.StringInterpolation {
    mutating func appendInterpolation(if condition: @autoclosure () -> Bool, _ literal: StringLiteralType) {
        guard condition() else { return }
        appendLiteral(literal)
    }
}

let doesSwiftRock = true
print("Swift rocks: \(if: doesSwiftRock, "(*)")")
print("Swift rocks \(doesSwiftRock ? "(*)" : "")")

Adding interpolations for custom types

You can add interpolations for your own custom types too:

struct Person {
    var type: String
    var action: String
}

extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person) {
        appendLiteral("I'm a \(person.type) and I'm gonna \(person.action).")
    }
}

let hater = Person(type: "hater", action: "hate")
print("Status check: \(hater)")

The nice thing about using string interpolation here is that we don’t touch the object’s debug description. So, if we view this thing in a debugger, or we try to print it out directly, we get the original, untouched data that we got previously:

print(hater)

We can even combine that custom type with the multiple parameters from earlier:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person, count: Int) {
        let action = String(repeating: "\(person.action) ", count: count)
        appendLiteral("\n\(person.type.capitalized)s gonna \(action)")
    }
}

let player = Person(type: "player", action: "play")
let heartBreaker = Person(type: "heart-breaker", action: "break")
let faker = Person(type: "faker", action: "fake")

print("Let's sing: \(player, count: 5) \(hater, count: 5) \(heartBreaker, count: 5) \(faker, count: 5)")

If you hadn’t already guessed, that’s the lyrics to Taylor Swift’s Shake it Off using string interpolation – sorry not sorry.

You can of course use the full range of Swift’s language features to craft your custom interpolations. For example, we could write a simple debug interpolation that accepts any kind of Encodable object and prints it as JSON:

mutating func appendInterpolation<T: Encodable>(debug value: T) {
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted

    if let result = try? encoder.encode(value) {
        let str = String(decoding: result, as: UTF8.self)
        appendLiteral(str)
    }
}

If we then make Person conform to Encodable we can print it out like this:

print("Here's some data: \(debug: faker)")

You can also use things like variadic parameters, and even mark your interpolations as throwing. For example, our Encodable generic interpolation does nothing if an encoding error happens, but if we wanted to we could use error propagation to make it bubble upwards:

mutating func appendInterpolation<T: Encodable>(debug value: T) throws {
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted

    let result = try encoder.encode(value)
    let str = String(decoding: result, as: UTF8.self)
    appendLiteral(str)
}

print(try "Status check: \(debug: hater)")

Everything we’ve looked at so far is just modifying the way strings do interpolation.

Building types with interpolation

As you’ve seen, this is really about controlling the way data is formatted in our apps in a really clean way, but we can also use this to build our own types using interpolation.

To demonstrate this we’re going to design a new type that can be instantiated from a string using interpolation. So, we’re going to make a type that creates attributed strings in various colors, all using string interpolation:

struct ColoredString: ExpressibleByStringInterpolation {
    // this nested struct is our scratch pad that assembles an attributed string from various interpolations
    struct StringInterpolation: StringInterpolationProtocol {
        // this is where we store the attributed string as we're building it
        var output = NSMutableAttributedString()

        // some default attribute to use for text
        var baseAttributes: [NSAttributedString.Key: Any] = [.font: UIFont(name: "Georgia-Italic", size: 64) ?? .systemFont(ofSize: 64), .foregroundColor: UIColor.black]

        // this initializer is required, and can be used as a performance optimization
        init(literalCapacity: Int, interpolationCount: Int) { }

        // called when we need to append some raw text
        mutating func appendLiteral(_ literal: String) {
            // print it out so you can see how it's called at runtime
            print("Appending \(literal)")

            // give it our base styling
            let attributedString = NSAttributedString(string: literal, attributes: baseAttributes)

            // add it to our scratchpad string
            output.append(attributedString)
        }

        // called when we need to append a colored message to our string
        mutating func appendInterpolation(message: String, color: UIColor) {
            // print it out again
            print("Appending \(message)")

            // take a copy of our base attributes and apply the color
            var coloredAttributes = baseAttributes
            coloredAttributes[.foregroundColor] = color

            // wrap it in a new attributed string and add it to our scratchpad
            let attributedString = NSAttributedString(string: message, attributes: coloredAttributes)
            output.append(attributedString)
        }
    }

    // the final attributed string, once all interpolations have finished    
    let value: NSAttributedString

    // create an instance from a literal string
    init(stringLiteral value: String) {
        self.value = NSAttributedString(string: value)
    }

    // create an instance from an interpolated string
    init(stringInterpolation: StringInterpolation) {
        self.value = stringInterpolation.output
    }
}

// now try it out!
let str: ColoredString = "\(message: "Red", color: .red), \(message: "White", color: .white), \(message: "Blue", color: .blue)"

Behind the scenes, this is really just massive amounts of syntactic sugar. We could do that last part entirely by hand if we wanted:

var interpolation = ColoredString.StringInterpolation(literalCapacity: 10, interpolationCount: 1)

interpolation.appendLiteral("Hello")
interpolation.appendInterpolation(message: "Hello", color: .red)
interpolation.appendLiteral("Hello")

let valentine = ColoredString(stringInterpolation: interpolation)

Wrap up

As you’ve seen, custom string interpolation lets us wrap up formatting code in one place, so we can keep our call sites clear. At the same time, it also delivers extraordinarily flexibility for us to create types in a really natural way.

Remember, this is only one tool in our toolbox – it's not our only tool. That means sometimes you'll use interpolation, other times functions, and other times something else. Like many things in programming, the key is to be pragmatic: to make choices on a case-by-case basis.

At the same time, this feature is also extremely new, so I really look forward to seeing what folks build with it once Swift 5.0 ships as final.

Speaking of Swift 5.0, string interpolation is just one of many great new features coming in Swift 5.0 – check out my site What’s New in Swift.

Further reading:

Hacking with Swift is sponsored by Proxyman.

SPONSORED Debug 10x faster with Proxyman. Your ultimate tool to capture HTTPs requests/ responses, natively built for iPhone and macOS. You’d be surprised how much you can learn about any system by watching what it does over the network.

Try Now!

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.6/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.