Is there any less verbose way to customize nil-valued optional types in string interpolation?

Is there any way to make Swift smarter about my intent in situations like this? I so often have a need to write code like this:

var startTime: Date? = ...
print("Start time: \(startTime ?? "<not started>")")

But I can't do that, because startTime is Optional<Date>, not String. So I end up writing something like:

print("Start time: \(startTime != nil ? String(describing: startTime) : "<not started>")")

I'm sure I could come up with my own nil-coalescing operator to do this, and maybe that's enough, but it would be cool if I could just be expressive like this in the language. Is it too much of a hack to say "inside string interpolation, if the type of the RHS of ?? doesn’t match that of the LHS, and it is String, interpolate the LHS first, and make the entire expression type String?"

I'm not sure what kind of useful expressions this would break, but none immediately come to mind. Then again, most of you seem to be much smarter about Swift than I am.

Update:

I was able to write this:

infix operator ???

func
???(inLHS: Optional<Any>, inRHS: String)
	-> String
{
	if let lhs = inLHS
	{
		return String(describing: lhs)
	}
	else
	{
		return inRHS
	}
}

But I don't know how to make the compiler complain if the LHS isn't an Optional type.

1 Like

As of Swift 5, you could extend string interpolation:

extension String.StringInterpolation {
  mutating func appendInterpolation<T>(maybe: T?) {
    if let value = maybe {
      appendInterpolation(value)
    } else {
      appendLiteral("nil")
    }
  }
}

let x: Int? = nil

"\(maybe: x)"
6 Likes

I tend to have types themselves already pre‐loaded with easily selectable description formats. Since they are strings anyway, the issue never occurs, and the compiler will catch you if it becomes non‐optional:

extension Date {
    var inWords: String {
        return "three o’clock"
    }
    var inNumbers: String {
        return "3:00 p.m."
    }
}

print("Start time: \(startTime?.inNumbers ?? "Not started yet.")")

I usually don’t want to interpolate an unknown, arbitrary, unlocalized description anyway, since it is so often a source of UI bugs. But if you do want that, you could swallow any type by extending optional itself:

extension Optional {
    var arbitraryDescription: String? {
        switch self {
        case .some(let value):
            return String(describing: value)
        case .none:
            return nil
        }
    }
}

print("Start time: \(startTime.arbitraryDescription ?? "Not started yet.")")

This is always in the context of debug logging, so no UI concerns.

1 Like

Maybe the problem here is that you’re trying to shove everything into the string interpolation which is why the code looks a bit messy. Try separating your code out into variables or use an extension to provide a string value for the date. Then you can put that into the interpolation.

let startTime = Date()
let startTimeStr = startTime.stringValue ?? “nil”
print(“Start time: \(startTimeStr)”)
2 Likes

Why not just use description explicitly?

print("Start time: \(startTime?.description ?? "<not started>")")
3 Likes

That helps, I guess I thought I had to write String(describing:), which doesn't work to give you an optional result.

It's still pretty verbose when you have a lot of values to interpolate.

Based on this, you could also implement one with a custom fallback string like this

extension String.StringInterpolation {
	mutating func appendInterpolation<T>(_ value: T?, or ifnil: @autoclosure () -> String) {
		if let value = value {
			appendInterpolation(value)
		}
		else {
			appendLiteral(ifnil())
		}
	}
}

let a: Int? = 1
let b: Int? = nil
print("""
	a: \(a, or: "No a :(")
	b: \(b, or: "No b :(")
	""")
2 Likes

Very nice solution. I have one issue thoguh, it does not work for SwiftUI Text()


          let a = "\(optionalValue: rating)" // works
          Text("\(optionalValue: rating)") // No exact matches in call to instance method 'appendInterpolation'

Why so? Does it not generate a String here? Do I have to extend StringProtocol here?

1 Like

It’s trying to call the Text initializer that takes a LocalizedStringKey. Cast it like

Text(“\(myOptional)” as String)

And it should work.

I tried duplicating that extension for LocalizedStringKey.StringInterpolation, and it did silence the errors, but running crashed in an infinite loop.

Edit: Got it working with the following, using string interpolation to break the infinite loop! Lol

extension LocalizedStringKey.StringInterpolation {
    mutating func appendInterpolation<T>(_ value: T?) {
        if let value {
            appendLiteral("\(value)")
        } else {
            appendLiteral("nil")
        }
    }
}
1 Like

appendInterpolation<T>(_ value: T?) would match any optional interpolation with a single argument.

Interpolations distinguished only by type can be confusing to the user, so I prefer using argument labels or at least multiple arguments in some canonical fashion.

Nil is one case of failure, but failure can include false Bool, empty string, etc. Giving all success/fail interpolations the same parameters makes it easier for users.

For any success/fail interpolation, I'd like to supply an alternate String in case of failure, and prefix/suffix in case of success.

So this is my interface for optionals:

public mutating func appendInterpolation<T>(
    _ prefix: String?,
    _ item: T?,
    _ suffix: String? = "",
    alt: String = ""
  ) { ... }

Sample uses:

// "\(foo)" or "" (using default alt value)
"\("", foo)" 

// "f(\(args))" or "n/a"
"\("f(", args, ")", alt: "n/a")" 

Here's a separate one for String, so failure can include the empty string (perhaps even after removing whitespace):

public mutating func appendInterpolation(
    _ prefix: String?,
    _ item: String?, // same for nil or empty
    _ suffix: String? = "",
    alt: String = ""
  ) { ... }

And here's Bool in the same position:

public mutating func appendInterpolation(
    _ prefix: String?,
    _ doPrint: Bool,
    _ suffix: String? = "",
    alt: String = ""
  ) { ... }

In the Bool case, an alt value is almost always specified for the false case, except when using an expression to silence a value.

You can manage sequences/collections similarly (e.g., with different results for nil, empty, single, and many).

And you can use interpolation in the arguments:

// "" or "Summary: \(summary)" or "Summary: \(summary) {\(details)}" 
"\("Summary: ", summary, "\(" {", details, "}")")"

// "[P2] r3.0.alpha FIXED" or "jane@dev.ours.com"
"\("[\(bug.priority)]", bug.release, "\(bug.resolution)", alt: "\(bug.assignee)")"

(Also consider replicating the system privacy argument when supporting possibly-private output.)

3 Likes

An Either type, combined with a variety of conditional conformances, is a very useful multi-tool that I think belongs in the standard library, and can be used to solve this without needing something specific to string interpolation.

The below defines a ?| operator, which is like the nil-coalescing operator, but creates an Either - which means the rhs can be a different type to the lhs, as is needed here:

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

extension Either: CustomStringConvertible 
where Left: CustomStringConvertible, Right: CustomStringConvertible {
  var description: String {
    switch self {
    case let .left(l): l.description
    case let .right(r): r.description
    }
  }
}

infix operator ?| : NilCoalescingPrecedence

// An operator similar to the nil-coalescing operator ??, but
// produces an Either type, allowing the default value on the
// right-hand side to be a different type to the Wrapped type.
func ?| <LHS,RHS>(lhs: LHS?, rhs: RHS) -> Either<LHS,RHS> {
    if let lhs { .left(lhs) } else { .right(rhs) }
}

let maybeArray: [Int]? = nil
let str = "The array was \(maybeArray ?| "unknown")"
11 Likes
extension Optional {
    static func ?? (self: Wrapped?, fallback: @autoclosure () -> String) -> String {
        if let self {
            String(describing: self)
        } else {
            fallback()
        }
    }
}

Given that, ?? works like you want.

You might want to restrict it however - e.g. limit it to where Wrapped conforms to CustomStringConvertible. That way it's less likely that you'll unintentionally use this overload (and it'll be more efficient since you can access description directly rather than with the overhead of going through String(describing:)).

There may be a noticeable performance penalty from overloading such a common operator, depending on how much you use ?? in affected modules.

You cannot make any other operator, or any function, behave like ?? w.r.t. not implicitly wrapping its arguments. Even though the implementation of the ?? operator looks straight-forward and nothing special, the Swift compiler has hard-coded special-casing for ?? (also here, here, here, here…). The best approximation of that which we mortals can obtain is to use member methods & properties of Optional to get a similar effect. e.g.:

extension Optional where Wrapped: CustomStringConvertible {
    // Since it's an actual member method of Optional,
    // it cannot be used on non-Optionals.
    func or(_ fallback: @autoclosure () -> String) -> String {
        self?.description ?? fallback()
    }
}

"\(startTime.or("<not started>"))"
// As opposed to:
"\(startTime?.description ?? "<not started>")"

If or is a bit too 'magical' for you, consider a more elaborate name like descriptionOr.

You can use a variation if your values aren't CustomStringConvertible. You can even remove all prerequisites from the wrapped type:

extension Optional {
    func or(_ fallback: @autoclosure () -> String) -> String {
        if let self {
            String(describing: self)
        } else {
            fallback()
        }
    }
}

…but that's a bit more dangerous since now there's not even an implied "stringiness" to the wrapped value. Limiting it to just things which are intuitively stringy (those that are CustomStringConvertible, or perhaps CustomDebugStringConvertible) makes it harder to abuse.

3 Likes