The environment in SwiftUI is sort of like a global dictionary but with stronger types: each key (represented by a key path) can have its own specific value type. For example, the \.isEnabled
key stores a boolean value, whereas the \.font
key stores an Optional<Font>
.
I wrote a custom dictionary type that can do the same thing. The HeterogeneousDictionary
struct I show in this article stores mixed key-value pairs where each key defines the type of value it stores. The public API is fully type-safe, no casting required.
I’ll start with an example of the finished API. Here’s a dictionary for storing text formatting attributes:
import AppKit
var dict = HeterogeneousDictionary<TextAttributes>()
dict[ForegroundColor.self] // → nil
// The value type of this key is NSColor
dict[ForegroundColor.self] = NSColor.systemRed
dict[ForegroundColor.self] // → NSColor.systemRed
dict[FontSize.self] // → nil
// The value type of this key is Double
dict[FontSize.self] = 24
dict[FontSize.self] // → 24 (type: Optional<Double>)
We also need some boilerplate to define the set of keys and their associated value types. The code to do this for three keys (font, font size, foreground color) looks like this:
// The domain (aka "keyspace")
enum TextAttributes {}
struct FontSize: HeterogeneousDictionaryKey {
typealias Domain = TextAttributes
typealias Value = Double
}
struct Font: HeterogeneousDictionaryKey {
typealias Domain = TextAttributes
typealias Value = NSFont
}
struct ForegroundColor: HeterogeneousDictionaryKey {
typealias Domain = TextAttributes
typealias Value = NSColor
}
Yes, this is fairly long, which is one of the downsides of this approach. At least you only have to write it once per “keyspace”. I’ll walk you through it step by step.
Using types as keys
As you can see in this line, the dictionary keys are types (more precisely, metatype values):
This is another parallel with the SwiftUI environment, which also uses types as keys (the public environment API uses key paths as keys, but you’ll see the types underneath if you ever define your own environment key).
Why use types as keys? We want to establish a relationship between a key and the type of values it stores, and we want to make this connection known to the type system. The way to do this is by defining a type that sets up this link.
A standard Dictionary
is generic over its key and value types. This doesn’t work for our heterogeneous dictionary because we have multiple value types (and we want more type safety than Any
provides). Instead, a HeterogeneousDictionary
is parameterized with a domain:
// The domain (aka "keyspace")
enum TextAttributes {}
var dict = HeterogeneousDictionary<TextAttributes>()
The domain is the “keyspace” that defines the set of legal keys for this dictionary. Only keys that belong to the domain can be put into the dictionary. The domain type has no protocol constraints; you can use any type for this.
Defining keys
A key is a type that conforms to the HeterogeneousDictionaryKey
protocol. The protocol has two associated types that define the relationships between the key and its domain and value type:
protocol HeterogeneousDictionaryKey {
/// The "namespace" the key belongs to.
associatedtype Domain
/// The type of values that can be stored
/// under this key in the dictionary.
associatedtype Value
}
You define a key by creating a type and adding the conformance:
struct Font: HeterogeneousDictionaryKey {
typealias Domain = TextAttributes
typealias Value = NSFont
}
A minimal implementation of the dictionary type is quite short:
struct HeterogeneousDictionary<Domain> {
private var storage: [ObjectIdentifier: Any] = [:]
var count: Int { self.storage.count }
subscript<Key>(key: Key.Type) -> Key.Value?
where Key: HeterogeneousDictionaryKey, Key.Domain == Domain
{
get { self.storage[ObjectIdentifier(key)] as! Key.Value? }
set { self.storage[ObjectIdentifier(key)] = newValue }
}
}
Internal storage
private var storage: [ObjectIdentifier: Any] = [:]
Internally, HeterogeneousDictionary
uses a dictionary of type [ObjectIdentifier: Any]
for storage. We can’t use a metatype such as Font.self
directly as a dictionary key because metatypes aren’t hashable. But we can use the metatype’s ObjectIdentifier
, which is essentially the address of the type’s representation in memory.
Subscript
subscript<Key>(key: Key.Type) -> Key.Value?
where Key: HeterogeneousDictionaryKey, Key.Domain == Domain
{
get { self.storage[ObjectIdentifier(key)] as! Key.Value? }
set { self.storage[ObjectIdentifier(key)] = newValue }
}
The subscript implementation constrains its arguments to keys in the same domain as the dictionary’s domain. This ensures that you can’t subscript a dictionary for text attributes with some other unrelated key. If you find this too restrictive, you could also remove all references to the Domain
type from the code; it would still work.
Types as keys don’t have the best syntax. I think you’ll agree that dict[FontSize.self]
doesn’t read as nice as dict[\.fontSize]
, so I looked into providing a convenience API based on key paths.
My preferred solution would be if users could define static helper properties on the domain type, which the dictionary subscript would then accept as key paths, like so:
extension TextAttributes {
static var fontSize: FontSize.Type { FontSize.self }
// Same for font and foregroundColor
}
Sadly, this doesn’t work because Swift 5.6 doesn’t (yet?) support key paths to static properties (relevant forum thread).
We have to introduce a separate helper type that acts as a namespace for these helper properties. Since the dictionary type can create an instance of the helper type, it can access the non-static helper properties. This doesn’t feel as clean to me, but it works. I called the helper type HeterogeneousDictionaryValues
as a parallel with EnvironmentValues
, which serves the same purpose in SwiftUI.
The code for this is included in the Gist.
Is the HeterogeneousDictionary
type useful? I’m not sure. I wrote this mostly as an exercise and haven’t used it yet in a real project. In most cases, if you need a heterogeneous record with full type safety, it’s probably easier to just write a new struct where each property is optional — the boilerplate for defining the dictionary keys is certainly longer and harder to read.
For representing partial values, i.e. struct-like records where some but not all properties have values, take a look at these two approaches from 2018:
These use a similar storage approach (a dictionary of Any
values with custom accessors to make it type-safe), but they use an existing struct as the domain/keyspace, combined with partial key paths into that struct as the keys. I honestly think that this is the better design for most situations.
Aside from the boilerplate, here are a few more weaknesses of HeterogeneousDictionary
:
- Storage is inefficient because values are boxed in
Any
containers - Accessing values is inefficient: every access requires unboxing
HeterogeneousDictionary
can’t easily conform toSequence
andCollection
because these protocols require a uniform element type
The full code is available in a Gist.