Figure

A blog about Swift and iOS development.

Mixing Constant and Literal Strings

Say we're writing an HTTP library. We're going to want a way to deal with headers.

func addHeader(_ header: String, value: String) {
  //...
}

Take a look at the signature of addHeaders. On the surface, there's no problem here. The spec roughly defines headers as a list of key/value pairs with both the key and the value being text. Seems pretty straight forward:

addHeader("Contant-Type", value: "text/html")

But it's not the wild west. HTTP headers have a number of well-known keys. And some, like "Content-Type" here, are used over and over again. And if we look above, we'll see I mistyped it.

No problem. We'll define a constant to use instead:

let kContentType = "Content-Type"
addHeader(kContentType, value: "text/html")

A great solution for the problem at hand. But we haven't dealt with the root issue: the interface is still inherently stringly typed. Nothing actually enforces the use of our constant, so…

//Me in a different file.
//After three years.
//And a bottle of Buffalo Trace.

addHeader("cantnt-tipy", value: "max/headroom")

Right. This is why we have enums.

enum HeaderKey {
  case accept
  case contentType
  case userAgent
  //...
}

func addHeader(_ header: HeaderKey, value:String) {
  //...
}

addHeader(.contentType, value: "max/headroom")

Great! Clean and very swifty. We could stop here…

Except that well-known headers aren't the whole story. Custom headers are very much a thing.

addHeader("X-Coke-Type", value: "New™ Coke®")
//🛑 cannot convert value of type 'String' 
//   to expected argument type 'HeaderKey'

How do we make room in our enum for unexpected and unknowable keys like this? We'll capture them in an associated value:

enum HeaderKey {
  case accept, contentType, userAgent
  case other(String)
}

addHeader(.contentType, value: "max/headroom")
addHeader(.other("X-Coke-Type"),
          value: "New™ Coke®")

And now we have an interesting decision to make. Do we want to enforce safe, well-known constants and provide an option to specify arbitrary strings? Or do we want to allow arbitrary strings and provide an option to specify safe, well-known constants?

Above I've chosen the former. But if the situation calls for the latter, we could easily make HeaderKey conform to ExpressibleByStringLiteral:

extension HeaderKey: ExpressibleByStringLiteral {
  public init(stringLiteral value: String) {
    self = .other(value)
  }
  //...
}

Then we could write our custom headers without .other:

addHeader("X-Coke-Type", value: "New™ Coke®")

Now, of course, there's nothing to stop us from fat-fingering "Contant-Type" as a string literal. But HeaderKey is still there in the signature and we can use .contentType if we choose.

Which of these approaches is correct? Neither and both — it's a trade off that depends on the use case. For our HTTP header example, though, it feels right to prioritize enumeration over custom strings.

Speaking of conformance to string protocols, so far we've been focusing on cleaning up the call site. But remember, ultimately, headers are text. So when we pass them to our networking libraries et al., we'll need to treat them like strings. That's what CustomStringConvertible is for:

extension HeaderKey: CustomStringConvertible {
  public var description: String {
    switch self {
    case .accept: return "Accept"
    case .contentType: return "Content-Type"
    case .userAgent: return "User-Agent"
    case .other(let s): return s
    }
  }
}

At this point, we might ask "Why not RawRepresentable?" It's true, RawRepresentable does almost exactly what we want. But it carries with it the extra overhead of initializing with a raw value which we'll never use.1 And String(describing:) is the cannonical way "to convert an instance of any type to its preferred representation as a string."

func addHeader(_ header: HeaderKey, value:String) {
  let headerText = String(describing: header)
  libraryExpectingAString(headerText)
}

Very rarely, in life or code, is any list absolute or certain. Situations come up all the time where we need an "escape hatch" from our carefully calculated set of pre-defined options.

When that happens, we don't need to throw our hands up in despair and make everything a String. Enumerations (combined with CustomStringConvertible and maybe even ExpressibleByStringLiteral) let us work around the 20% case while not jeopardizing the safety and convenience of the 80% out there.


1: Still, RawRepresentable is way cooler than it's often given credit for, and those interested should read Ole Begemann's amazing write up on manually implementing the protocol.↩︎


Hit me up on twitter (@jemmons) to continue the conversation.

Latest | Archives