Improving Type Name Computation Performance
Introduction
The recommended way to obtain a type name from a generic type parameter is:
-
String(describing: type(of:))— returns the type name without the module prefix -
String(reflecting: type(of:))— returns the fully qualified type name, including the module prefix (e.g.,Foundation.Data)
However, both String(describing:) and String(reflecting:) carry significant performance overhead that may be unacceptable in performance-sensitive code paths.
Deep Dive
Performance Overhead
Internally, String(reflecting: type(of: self)) performs multiple protocol conformance checks in OutputStream.swift, testing against:
-
CustomDebugStringConvertible -
CustomStringConvertible -
TextOutputStreamable -
CustomReflectable(inside theMirrorinitializer)
The first conformance check for any given (type, protocol) pair is expensive because the runtime must scan all protocol conformance type descriptors. The same overhead applies to String(describing:) (OutputStream.swift).
As an application grows and accumulates more protocol conformance descriptors, the cost of the first call to String(describing:) or String(reflecting:) for any given type increases proportionally.
How Type Names Are Computed Internally
Despite the overhead above, both String(reflecting:) and String(describing:) ultimately delegate to the internal _typeName(_:qualified:) function:
-
String(reflecting:)calls it withqualified: true -
String(describing:)calls it withqualified: false
All of the protocol conformance scanning described above is therefore unnecessary overhead when the goal is simply to retrieve a type name.
Full call-stack:
-
_debugPrint_unlocked. Because Any.Type does not conform any of checked protocols.
Performance Measurements
In our production app, which contains approximately 200,000 protocol conformance descriptors, the first call to String(describing:) or String(reflecting:) for a given Any.Type takes 6–9 ms on an iPhone 13.
To isolate and quantify this overhead, I wrote a benchmark running on an M2 Max with 32 GB RAM.
First Call (nearly empty binary)
| Method | Duration |
|---|---|
String(describing:) / String(reflecting:) |
0.6 – 1.1 ms |
_typeName(_:qualified:) |
0.0085 – 0.03 ms |
Subsequent Calls for the Same Type
| Method | Standard types (Int, Double, non-generic) |
Generic types |
|---|---|---|
String(describing:) / String(reflecting:) |
600 – 690 ns | 600 – 690 ns |
_typeName(_:qualified: false) |
10 – 12 ns | 40 – 50 ns |
_typeName(_:qualified: true) |
10 – 12 ns | 40 – 50 ns |
Summary
First call (nearly empty binary):
-
String(reflecting:)is 40–100× slower than_typeName(_:qualified: true) -
String(describing:)is 40–130× slower than_typeName(_:qualified: false)
Subsequent calls for the same type:
-
String(reflecting:)is 13–50× slower than_typeName(_:qualified: true) -
String(describing:)is 14–60× slower than_typeName(_:qualified: false)
Current Workaround and Its Limitations
In our codebase, we rely heavily on _typeName(_:qualified:) to avoid this overhead. Unfortunately, the leading underscore indicates that this function is officially unstable and unsupported, meaning we cannot depend on it in production code long-term.
This is the motivation for proposing a proper public API solution.
Proposed Solutions
Option 1: Add Any.Type Overloads for String(reflecting:) and String(describing:)
Introduce overloads that accept an Any.Type argument and delegate directly to _typeName(_:qualified:), bypassing all unnecessary protocol conformance checks.
public init(reflecting subject: Any.Type) {
self = _typeName(subject, qualified: true)
}
public init(describing subject: Any.Type) {
self = _typeName(subject, qualified: false)
}
Precedent: Similar type-specific overloads already exist, such as DefaultStringInterpolation.appendInterpolation.
Pros:
-
No call-site changes required. Existing code using String(describing: type(of: x))orString(reflecting: type(of: x))will automatically resolve to the new, faster overload after upgrading to a stdlib that includes it. -
Full performance benefit with no manual migration effort.
Cons:
Introduces new overloads, which could theoretically affect overload resolution in edge cases.
Option 2: Introduce a Public typeName(_:qualified:) Function
Expose _typeName(_:qualified:) as a supported public API by removing the underscore prefix.
Pros:
-
A dedicated, clearly named function is more semantically precise than repurposing String(describing:)orString(reflecting:)for type name lookup. -
Full performance benefit.
Cons:
-
Requires updating all existing call sites manually. -
Adds a new top-level function to the standard library.
Option 3: Optimize Swift Runtime Casting to Short-Circuit for Metatypes
Modify the Swift runtime so that when the argument to String(describing:) or String(reflecting:) is a metatype, the protocol conformance scanning is skipped entirely.
I am currently exploring the Swift runtime source to understand whether this is feasible, but I do not yet have a concrete implementation approach.
Pros:
No new public API surface.
Cons:
-
Likely complex to implement correctly. -
Even if implemented, some overhead from protocol casting and Mirrorinitialization would remain without also addressing theStringAPI layer.
My opinion
Option 1 is the most practical path forward. It delivers the full performance improvement with zero migration cost for existing code, and the pattern of providing type-specialized overloads is already established in the standard library.