Dynamically Constructing Generic Types in Swift
Swift's type system is powerful but sometimes challenging, especially when dealing with generic types at runtime.
In this post, I'll explore how to dynamically construct generic types in Swift - a technique that's particularly useful for framework developers and those working with advanced Swift patterns.
The Challenge
In statically typed languages like Swift, we typically work with types that are known at compile time.
But what if we need to construct a generic type dynamically at runtime? For example, imagine we need to create an instance of Optional<T>
or Array<T>
where T
is only known at runtime.
This is a common challenge when:
- Building reflection systems
- Implementing serialization/deserialization frameworks
- Creating DSL UI frameworks (like SwiftUI/OpenSwiftUI)
Understanding Swift's Type Metadata
To dynamically construct generic types, we need to understand Swift's runtime type system.
Swift maintains type metadata for every type used in our program, which contains information about the type's structure, inheritance, and generic parameters.
Swift Metadata Layout for Structs
Before diving into the struct example, it's important to understand the layout of Swift metadata for structs.
On ABI-stable platforms (like iOS, macOS, etc.), struct metadata1 follows a specific layout (each row represents 8 bytes):
Offset | Description |
---|---|
-1 | Value Witness Table pointer |
0 | Kind (struct = 0x200) |
1 | Nominal Type Descriptor |
2 | Generic Argument Vector (if generic) |
While this diagram shows struct metadata specifically, other Swift types such as classes, enums, and others follow similar patterns with type-specific variations. For our example, focusing on the struct implementation is sufficient.
Also, we'll use VWT to represent the Value Witness Table and NTD to represent the Nominal Type Descriptor for brevity in the following sections.
Implement Metadata type in Swift
First, we define a Metadata2 type to help us handle the runtime type information:
typedef struct OG_SWIFT_NAME(_Metadata) OGSwiftMetadata {
} OGSwiftMetadata;
typedef const OGSwiftMetadata *OGTypeID OG_SWIFT_STRUCT OG_SWIFT_NAME(Metadata);
Then we can add nominal descriptor support to our metadata type:
OG_EXPORT
OG_REFINED_FOR_SWIFT
void const* _Nullable OGTypeNominalDescriptor(OGTypeID typeID) OG_SWIFT_NAME(getter:Metadata.nominalDescriptor(self:));
void const* OGTypeNominalDescriptor(OGTypeID typeID) {
auto metadata = reinterpret_cast<OG::swift::metadata const*>(typeID);
return metadata->nominal_descriptor();
}
#include <swift/Runtime/Metadata.h>
#include <swift/Runtime/HeapObject.h>
using namespace swift;
namespace OG {
namespace swift {
class metadata: public Metadata {
public:
OG_INLINE OG_CONSTEXPR
TypeContextDescriptor const* descriptor() const OG_NOEXCEPT {
switch (getKind()) {
case MetadataKind::Class: {
const auto cls = static_cast<const ClassMetadata *>(getType());
// We may build this with a newer OS SDK but run on old OS.
// So instead of using `isTypeMetadata` / `(Data & SWIFT_CLASS_IS_SWIFT_MASK)`,
// we manully use 3 here to check isTypeMetadata
if ((cls->Data & 3) == 0) return nullptr;
return cls->getDescription();
}
case MetadataKind::Struct:
case MetadataKind::Enum:
case MetadataKind::Optional: {
return static_cast<const TargetValueMetadata<InProcess> *>(getType())->Description;
}
default:
return nullptr;
}
}
OG_INLINE OG_CONSTEXPR
TypeContextDescriptor const* nominal_descriptor() const OG_NOEXCEPT {
auto descriptor = this->descriptor();
if (descriptor == nullptr) {
return nullptr;
}
switch(descriptor->getKind()) {
case ContextDescriptorKind::Struct:
case ContextDescriptorKind::Enum:
return descriptor;
default:
return nullptr;
}
}
}; /* OG::swift::metadata */
} /* OG::swift */
} /* OG */
Also remember to add some Swifty interface for the Metadata type:
extension Metadata {
@inlinable
@inline(__always)
public init(_ type: any Any.Type) {
self.init(rawValue: unsafeBitCast(type, to: UnsafePointer<_Metadata>.self))
}
@inlinable
@inline(__always)
public var type: any Any.Type {
unsafeBitCast(rawValue, to: Any.Type.self)
}
}
Finally, we'll add a generic type information accessor to the Metadata type:
extension Metadata {
package func genericType(at index: Int) -> any Any.Type {
UnsafeRawPointer(rawValue)
.advanced(by: index &* 8) // Each generic argument takes 8 bytes
.advanced(by: 16) // Skip 16 bytes (kind + NTD) to reach generic arguments
.assumingMemoryBound(to: Any.Type.self)
.pointee
}
}
Implement ProtocolDescriptor type
Before we dive into our concrete example, another important part is to implement the ProtocolDescriptor
type.
// MARK: - ProtocolDescriptor
package protocol ProtocolDescriptor {
static var descriptor: UnsafeRawPointer { get }
}
extension ProtocolDescriptor {
package static func conformance(of type: any Any.Type) -> TypeConformance<Self>? {
guard let conformance = swiftConformsToProtocol(type, descriptor) else {
return nil
}
return TypeConformance(storage: (type, conformance))
}
}
// MARK: - TypeConformance
package struct TypeConformance<P> where P: ProtocolDescriptor {
package let storage: (type: any Any.Type, conformance: UnsafeRawPointer)
package init(storage: (type: any Any.Type, conformance: UnsafeRawPointer)) {
self.storage = storage
}
package var type: any Any.Type {
storage.type
}
package var conformance: UnsafeRawPointer {
storage.conformance
}
package var metadata: UnsafeRawPointer {
unsafeBitCast(storage.type, to: UnsafeRawPointer.self)
}
}
@_silgen_name("swift_conformsToProtocol")
private func swiftConformsToProtocol(
_ type: Any.Type,
_ protocolDescriptor: UnsafeRawPointer
) -> UnsafeRawPointer?
And then we can implement the ConditionalProtocolDescriptor
type:
// MARK: - ConditionalProtocolDescriptor
package protocol ConditionalProtocolDescriptor: ProtocolDescriptor {
static func fetchConditionalType(key: ObjectIdentifier) -> ConditionalTypeDescriptor<Self>?
static func insertConditionalType(key: ObjectIdentifier, value: ConditionalTypeDescriptor<Self>)
}
// MARK: - ConditionalTypeDescriptor
package struct ConditionalTypeDescriptor<P> where P: ConditionalProtocolDescriptor {
fileprivate enum Storage {
case atom(TypeConformance<P>)
indirect case optional(any Any.Type, ConditionalTypeDescriptor<P>)
indirect case either(any Any.Type, f: ConditionalTypeDescriptor<P>, t: ConditionalTypeDescriptor<P>)
}
private var storage: Storage
var count: Int
fileprivate init(storage: Storage, count: Int) {
self.storage = storage
self.count = count
}
}
Runtime Construction of Generic Types for Struct Example
Now that we understand the challenge and have explored all the infrastructure needed to deal with Swift's runtime type system, let's explore a practical approach using SwiftUI's _ConditionalContent
as our example. This type is particularly interesting because it demonstrates the complexity of constructing generic types at runtime:
@frozen
public struct _ConditionalContent<TrueContent, FalseContent> {
@frozen
public enum Storage {
case trueContent(TrueContent)
case falseContent(FalseContent)
}
public let storage: Storage
}
What we want to achieve is add a ConditionalTypeDescriptor.init(_:)
method to init a ConditionalTypeDescriptor
type from any dynamic Any.Type
type:
For Optional<T>
type, it's straightforward
- We first get a NTD for
Optional<Void>
type. - Since every
Optional<T>
type will have the same NTD of Optional enum type, we use it to check if the type is anOptional
type. - If it is, we then call descriptor method to recursively get the
ConditionalTypeDescriptor
instance for the wrapped type T, and init anoptional
case forConditionalTypeDescriptor
with the wrapped type.
let optionalTypeDescriptor: UnsafeRawPointer = Metadata(Void?.self).nominalDescriptor!
extension ConditionalTypeDescriptor {
fileprivate static func descriptor(type: any Any.Type) -> Self {
if let descriptor = P.fetchConditionalType(key: ObjectIdentifier(type)) {
return descriptor
} else {
let descriptor = ConditionalTypeDescriptor(type)
P.insertConditionalType(key: ObjectIdentifier(type), value: descriptor)
return descriptor
}
}
init(_ type: any Any.Type) {
let storage: Storage
let count: Int
let metadata = Metadata(type)
let descriptor = metadata.nominalDescriptor
switch descriptor {
case optionalTypeDescriptor:
let wrappedDescriptor = Self.descriptor(type: metadata.genericType(at: 0))
storage = .optional(type, wrappedDescriptor)
count = wrappedDescriptor.count + 1
default: ...
}
self.init(storage: storage, count: count)
}
}
For _ConditionalContent<Void, Void>.Storage
type, it's a bit more complex, and we can try to use the same approach to construct it:
- We first get a NTD for
_ConditionalContent<Void, Void>
type. - Since every
_ConditionalContent<TrueContent, FalseContent>
type will have the same NTD of _ConditionalContent struct type, we use it to check if the type is a_ConditionalContent
type. - If it is, we then get the descriptor for the dynmanic
TrueContent
andFalseContent
types, and init aneither
case forConditionalTypeDescriptor
with theTrueContent
andFalseContent
types.
The tricky and challenging part is how to get the descriptor for the dynamic _ConditionalContent<TrueContent, FalseContent>.Storage
type.
private let conditionalTypeDescriptor: UnsafeRawPointer = Metadata(_ConditionalContent<Void, Void>.self).nominalDescriptor!
extension ConditionalTypeDescriptor {
init(_ type: any Any.Type) {
let storage: Storage
let count: Int
let metadata = Metadata(type)
let descriptor = metadata.nominalDescriptor
switch descriptor {
case optionalTypeDescriptor: ...
case conditionalTypeDescriptor:
let falseDescriptor = Self.descriptor(type: metadata.genericType(at: 1))
let trueDescriptor = Self.descriptor(type: metadata.genericType(at: 0))
// How to get the dynamic metadata type for _ConditionalContent<TrueContent, FalseContent>.Storage here?
storage = .either(?, f: falseDescriptor, t: trueDescriptor)
count = falseDescriptor.count + trueDescriptor.count
default: ...
}
self.init(storage: storage, count: count)
}
}
Let's just skip it and focus on the last default part for now.
It's pretty straightforward, we just get the conformance for the type, and init an atom
case for ConditionalTypeDescriptor
with the conformance.
default:
storage = .atom(P.conformance(of: type)!)
count = 1
Finally, let's deal with the most challenging aspect of this implementation: constructing the _ConditionalContent<?, ?>.Storage
type at runtime.
While I haven't found an ideal solution yet, I've developed a working approach that leverages Swift's runtime type system.
The current implementation follows these steps:
- Get the NTD for
_ConditionalContent<Void, Void>.Storage
- Advance the NTD pointer by 12 bytes to get the metadata accessor function pointer
- Invoke the accessor function with corresponding arguments to get the final type we want
Here's how it looks in code3:
typealias Accessor = @convention(c) (UInt, Metadata, Metadata) -> Metadata
let nominal = Metadata(_ConditionalContent<Void, Void>.Storage.self).nominalDescriptor!
// Accessing the metadata accessor function pointer:
// NTD memory layout (simplification):
// [0-4]: Flags
// [4-8]: Parent
// [8-12]: Name
// [12-16]: Accessor function pointer relative offset
let accessorRelativePointer = nominal.advanced(by: 12)
let accessor = unsafeBitCast(
accessorRelativePointer.advanced(by:Int(accessorRelativePointer.assumingMemoryBound(to: Int32.self).pointee)),
to: Accessor.self
)
let type = accessor(0, Metadata(metadata.genericType(at: 0)), Metadata(metadata.genericType(at: 1)))
While this solution works, it's admittedly not as elegant as I'd prefer. It relies on implementation details of Swift's runtime that could change in future versions, making it somewhat fragile.
I'm actively exploring more robust alternatives and would welcome insights from the community. If you've tackled similar challenges or have suggestions for improving this approach, I'd love to hear from you!
This exploration into Swift's type system demonstrates both the power and complexity of working with generic types at runtime. While Swift's static type system provides safety and performance, these techniques allow us to push beyond those boundaries when necessary for advanced use cases.
The implementation detail can be found at OpenGraph's Runtime part↩
The implementation detail can be found at OpenSwiftUI↩