Why you’re not supposed to call description

Swift provides the Custom​String​Convertible and Custom​Debug​String​Convertible protocols to allow types to provide custom textual descriptions of themselves.

If you read the documentation for these protocols, you’ll notice that accessing a value’s description or debugDescription directly is discouraged. Instead, you’re supposed to use the corresponding String initializers, String(describing:) and String(reflecting:).

Every value is string-convertible

The reason for this rule is that every value in Swift is convertible to a string, regardless of a type’s conformance to one or both of these protocols. The protocols only exist to allow customization of a type’s textual representation. In particular, don’t ever use these protocols as types or generic constraints.

That said, there’s certainly nothing wrong with calling description on a concrete value in a local context. For example, there are at least three equivalent ways to convert a list of integers to strings:

(1...5).map { $0.description }        // → ["1", "2", "3", "4", "5"]
(1...5).map { "\($0)" }               // same
(1...5).map(String.init(describing:)) // same

Which one you prefer is largely a matter of taste.

Don’t use CustomStringConvertible as a constraint

However, you should absolutely not use the following pattern, where a function argument is constrained to Custom[Debug]StringConvertible, either with a generic constraint or a plain type specification:

func doSomething1<T>(with x: T) where T: CustomStringConvertible {
    // ...
    // Call x.description
}

func doSomething2(with x: CustomStringConvertible) {
    // ...
    // Call x.description
}

Both of these functions compile, but they needlessly constrain the set of accepted inputs — for example, they don’t accept types that only conform to Custom​Debug​String​Convertible, even though that’s a perfectly valid way to provide a textual representation.

Instead, the function should accept any type, because anything is printable:

func doSomething3<T>(with x: T) {
    // ...
    // Call String(describing: x)
}

Note that you can’t call description in the function body anymore, but you can call String(describing:).

How String(describing:) works

When you call String(describing:) on a value, the standard library follows a set of rules to find a suitable textual representation for the argument. Let’s take a quick look how this works.

  1. String(describing:) creates a new empty string and passes the argument and the empty string to a function named _print_unlocked. This is the implementation::

    extension String {
      public init<Subject>(describing instance: Subject) {
        self.init()
        _print_unlocked(instance, &self)
      } 
    }
    
  2. _print_unlocked looks like this:

    internal func _print_unlocked<T, TargetStream : TextOutputStream>(
      _ value: T, _ target: inout TargetStream
    ) {
      if _isOptional(type(of: value)) {
        let debugPrintable = value as! CustomDebugStringConvertible
        debugPrintable.debugDescription.write(to: &target)
        return
      }
      if case let streamableObject as TextOutputStreamable = value {
        streamableObject.write(to: &target)
        return
      }
       
      if case let printableObject as CustomStringConvertible = value {
        printableObject.description.write(to: &target)
        return
      }
       
      if case let debugPrintableObject as CustomDebugStringConvertible = value {
        debugPrintableObject.debugDescription.write(to: &target)
        return
      }
       
      let mirror = Mirror(reflecting: value)
      _adHocPrint_unlocked(value, mirror, &target, isDebugPrint: false)
    }
    

    The function first checks if the value to be printed is an Optional, and if so, prints the optional’s debugDescription. The force-cast to Custom​Debug​String​Convertible is safe here because the standard library knows that Optional conforms. Printing optionals with their debug description is preferred because optionals are not suitable for display to the user anyway.

  3. If we’re not dealing with an optional, _print_unlocked then tests the value consecutively for conformance to Text​Output​Streamable, Custom​String​Convertible, and Custom​Debug​String​Convertible, in that order. It uses the first match to generate the description.

    This is how a type that only conforms to Custom​Debug​String​Convertible can also be printed with String(describing:).

  4. If no matching conformance was found, the final fallback is a function named _adHocPrint_unlocked, which uses the value’s Mirror representation to print its components.

String(reflecting:) essentially works the same way, only with the protocol conformances checked in a different order.

LosslessStringConvertible

I should also mention a third string conversion protocol: Lossless​String​Convertible. This protocol refines Custom​String​Convertible and adds a semantic constraint:

The description property of a conforming type must be a value-preserving representation of the original value.

The string representation of a value that conforms to Lossless​String​Convertible preserves all the information that’s needed to recreate the value. It’s a guarantee that you can use the string representation e.g. to serialize the value without loss of information.

All trivial types in the standard library (Bool, the integer and floating-point types, Unicode.Scalar) as well as Character, String, and Substring conform to Lossless​String​Convertible.

An exception to the rule

When you rely on LosslessStringConvertible semantics, you should absolutely access the description property directly, despite the above advice to the contrary. As we have seen, the alternative String(describing:) prefers Text​Output​Streamable’s representation over description if it’s available, and you can’t be 100 % certain that representation is identical to the value’s description, however unlikely any differences are.