[Pitch] Default values for string interpolations

Greetings! Here's a pitch for a small feature that will hopefully alleviate a large annoyance.


Introduction

A new string interpolation syntax that allows a developer to provide a default string when interpolating an optional value.

Motivation

String interpolations are a streamlined and powerful way to include values within a string literal. When one of those values is optional, however, interpolating is not so simple; in many cases, a developer must fall back to unpalatable code or output that exposes type information.

For example, placing an optional string in an interpolation yields an important warning and two suggested fixes, only one of which is ideal:

let name: String? = nil
print("Hello, \(name)!")
// warning: string interpolation produces a debug description for an optional value; did you mean to make this explicit?
// print("Hello, \(name)!")
//                 ^~~~
// note: use 'String(describing:)' to silence this warning
// print("Hello, \(name)!")
//                 ^~~~
//                 String(describing:  )
// note: provide a default value to avoid this warning
// print("Hello, \(name)!")
//                 ^~~~
//                      ?? <#default value#>

The first suggestion, adding String(describing:), silences the warning but includes nil in the output of the string — maybe okay for a quick shell script, but not really appropriate result for anything user-facing.

The second suggestion is good, allowing us to provide whatever default string we'd like:

let name: String? = nil
print("Hello, \(name ?? "new friend")!")

However, the nil-coalescing operator (??) only works with values of the same type as the optional value, making it awkward or impossible to use when providing a default for non-string types. In this example, the age value is an optional Int, and there isn't a suitable integer to use when it's nil:

let age: Int? = nil
print("Your age: \(age)")
// warning, etc....

To provide a default string when age is missing, we have to write some gnarly code, or split out the missing case altogether:

let age: Int? = nil
// Optional.map
print("Your age: \(age.map(String.init(describing:)) ?? "missing")")
// Ternary expression
print("Your age: \(age != nil ? "\(age!)" : "missing")")
// if-let statement
if let age {
    print("Your age: \(age)")
} else {
    print("Your age: missing")
}

Proposed solution

The standard library should add an interpolation overload that lets you write the intended default as a string, no matter what the type of value.

let age: Int? = nil
print("Your age: \(age, default: "missing")")

This addition will improve the clarity of code that uses string interpolations and encourage developers to provide sensible defaults instead of letting nil leak into string output.

Detailed design

The implementation of this new interpolation overload looks like this:

extension String.StringInterpolation {
    mutating func appendInterpolation<T>(
        _ value: T?, 
        default: @autoclosure () -> String
    ) {
        self.appendLiteral(value.map(String.init(describing:)) ?? `default`())
    }
}

You can try this out yourself by copy/pasting the snippet above into a project or playground, or by experimenting with this Swift Fiddle.

Source compatibility

This proposal adds one new API to the standard library, which should not be source-breaking for any existing projects. If a project or a dependency has added a similar overload, it will take precedence over the new standard library API.

ABI compatibility

This proposal is purely an extension of the ABI of the
standard library and does not change any existing features.

Implications on adoption

The new API will be included in a new version of the Swift runtime, and could be marked as backward deployable.

Future directions

None considered.

Alternatives considered

None considered.

101 Likes

A terser alternative is just always allow a String (inside interpolation) as the fallback for ??. It's more special-casey but it's intuitive and minimises noise inside string interpolations (inside which it is super valuable to be terse, as they become unwieldy very quickly).

2 Likes

I much prefer the proposed approach over a new overload of ?? that is somehow only valid in string-interpolation contexts.

19 Likes

Lots of love from all us print debuggers for this one! :heart:

18 Likes

I love this and how much less friction it adds vs string describing.

1 Like

Simple, straightforward, consistent with existing API on Dictionary… ship it!

6 Likes

I really like this proposal. Will try it out in a project based on the code snippet you provided to see how it works in practice.

Honestly, both the \(age, default: "missing") syntax as well as the ?? would work for me.

+1 I absolutely love this.

On a related note, the fixit to replace an optional with String(describing: someOptional) implies an equally useful addition:

extension String.StringInterpolation {
    
    public mutating func appendInterpolation<Value>(describing value: Value?) {
        self.appendInterpolation(String(describing: value))
    }
    
}

This would let you do print("Hello, \(describing: possiblyNilName)") without a warning about interpolating a nil value.

16 Likes

I would absolutely use \(describing: ) for e.g. log statements, but I’m not sure the compiler should offer it as a fixit. \(_:, default:) is appropriate for all strings, including user-facing ones. (Though it’s not necessarily compatible with localization…)

4 Likes

I think the localization interpolations would probably end up going through things like LocalizedStringKey.StringInterpolation, not String.StringInterpolation, so there's probably better fix-its that can be targeted for those cases.

In that case, is () -> String the best type for the default argument? For a localized string you’d probably want it to be typed as () -> LocalizedStringKey, no?

As proposed, would this convenience would only exist on non-localized strings? Is there a way to define this convenience once for all string-interpolatable types?

3 Likes

That's an interesting point!

Instead of extending String.StringInterpolation, we could extend StringInterpolationProtocol where StringLiteralType == String. That will also provide the same functionality to LocalizedStringKey, though I don't know if that would require additional work inside the defining framework.

3 Likes

Is there official guidance on where such string interpolations should be defined?

When I asked about creating a "\(n, radix: r)" interpolation a few years ago, one of the replies said:

Yeah I think I agree with this. As appealing as it sounds to have default: apply to every string-interpolatable type everywhere, I wonder if that's too broad a brush to paint. Can we guarantee that every string interpolatable type would want that?

I love this pitch and see no drawbacks.

Do you mean changing autoclosed parameter to a mere String? Perhaps even StaticString?


I missed that thread and also love it. Maybe even a more complicated version:

let val = 256
"\(val, radix: 0x10, prefix: "0x", width: 8, leadingZeroes: true)"
// "0x00000100"

with appropriately defaulted arguments, and ability to pass various integer types, not just Int.

2 Likes

I mean allowing e.g. "\(maybeAnInt ?? "none")". You can overload ?? today, I believe, to achieve this same effect, but it applies everywhere, which may be going too far. Inside string interpolation specifically, though, it's arguably the right trade-off between convenience and the risk of uncaught errors from the looser type-checking.

1 Like

I see. Compared to the pitch, allowing such a heterogeneous "??" in the context of string interpolation only is a compiler change, right?

1 Like

There is one more variant without force unwrap:
print("Your age: \(age.map { "\($0)" } ?? "missing")")

+1 for the pitch , seems like a missing feature

With an Either type, no compiler magic is needed. See this Swift Fiddle.

extension String.StringInterpolation {
    mutating func appendInterpolation<T>(_ value: Either<T, String>) {
        let string = switch value {
            case .left(let value): String(describing: value)
            case .right(let value): value
        }
        appendLiteral(string)
    }
}

enum Either<Left, Right> {
    case left(Left)
    case right(Right)
}

func ?? <Left, Right>(lhs: Left?, rhs: @autoclosure () -> Right) -> Either<Left, Right> {
    if let lhs {
        .left(lhs)
    } else {
        .right(rhs())
    }
}

let name: String? = nil
print("Hello, \(name ?? "new friend")!")

let age: Int? = nil
print("Your age: \(age ?? "missing")")

Similarly to this use-case on the other thread, instead of using a general purpose Either, we can scope it locally to a single-purpose StringInterpolationResult enum.

1 Like

But that would be available not just in string interpolation but everywhere which is probably going too far:

let age: Int? = nil
let s = age ?? "missing" // unwanted feature

To make this available just in the context of string interpolation would require a compiler change, no?