A Swift Macro expands the stored properties of a type into computed properties that access to a storage dictionary. Inspired by WWDC 2023 Video Expand on Swift macros, the sample code of swift-syntax and Nikita Ermolenk's Automating RawRepresentable Conformance with Swift Macros.
Swift's Codable
is great for type safety. But sometimes, you need to work with a JSON object that changes often or has many different versions of it.
Suppose you have the following JSON object in version 1 of your app:
{
"name": "John Doe",
"age": 30
}
Your Person
type would be something like this:
struct Person: Codable {
var name: String
var age: Int
}
It works great. Then later in version 2, you add a new field:
{
"name": "John Doe",
"age": 30,
"gender": "male"
}
The Person
type for version 2 would be:
struct Person: Codable {
enum Gender: String, Codable {
case male, female
}
var name: String
var age: Int
var gender: Gender
}
But what if your customer's iPhone is updated to version 2 while their iPad remains on version 1, and the Person
object is updated on both devices? The iPhone version saves the gender
field but the iPad version then overwrites the JSON object without that field, the data gets lost due to the differences in app versions.
Similarly, if additional cases are added to the enum in a newer version of the app, the older version of the app no longer be able to decode the object.
struct Person: Codable {
enum Gender: String {
case male, female, neutral, trans, .....
}
}
let json =
"""
{
"name" : "John Doe",
"age" : 30,
"gender" : "trans"
}
""".data(using: .utf8)!
// On an older version of the app
let person = try JSONDecoder(Person.self, json)
// DecodingError: Cannot initialize `Gender` from invalid String value "trans"
One way to solve this problem is to decode the JSON into a raw dictionary and access them from your type's computed properties:
struct Person {
enum Gender: RawRepresentable {
case male, female
case unknown(String)
var rawValue: String {
switch self {
case .male:
return "male"
case .female:
return "female"
case .unknown(let value):
return value
}
}
init?(rawValue: String) {
switch rawValue {
case "male":
self = .male
case "female":
self = .female
default:
self = .unknown(rawValue)
}
}
}
var name: String {
get {
return _storage["name"] as? String ?? ""
}
set {
_storage["name"] = newValue
}
}
var age: Int? {
get {
return _storage["age"] as? Int
}
set {
_storage["age"] = newValue
}
}
private var _storage: [String: Any]
init(_ dictionary: [String: Any]) {
self._storage = dictionary
}
}
But this involves a significant amount of boilerplate.
Using @DictionaryStorage
, you can write just like this:
@DictionaryStorage
struct Person {
@StringRawRepresentation
enum Gender {
case male, female
case unknown(String)
}
var name: String
var age: Int?
}
The macro expands the code for you like the example above.
To use @DictionaryStorage
:
-
Installation
In Xcode, add DictionaryStorage with:
File
→Add Package Dependencies…
and input the package URL:https://github.com/naan/DictionaryStorage
Or, for SPM-based projects, add it to your package dependencies:
dependencies: [ .package(url: "https://github.com/naan/DictionaryStorage", from: "1.0.0") ]
And then add the product to all targets that use DictionaryStorage:
.product(name: "DictionaryStorage", package: "DictionaryStorage"),
-
Import & basic usage
After importing DictionaryStorage, add@DictionaryStorage
before your struct/class definition. The macro expands non-private stored properties into computed properties as well as adds an initializer and a read-only accessor to the backed dictionary.import DictionaryStorage @DictionaryStorage struct Person: Identifiable { let name: String = "" var age: Int? private let _id = UUID() var id: UUID { _ id } }
The macro expands the code to:
struct Person: Identifiable { let name: String = "" { get { _storage["name"] as? String ?? "" } set { _storage["name"] = newValue } } var age: Int? { get { _stoarge["age"] as? Int } set { if newValue { _storage["age"] = newValue } } } // DictionaryStorage does not expand private, nor computed properties. private let _id = UUID() var id: UUID { _id } private var _storage: [String: Any] init(_ dictionary: [String: Any]) { _storage = dictionary } var rawDictionary: [String: Any] { _storage } } extension Person: DictionaryRepresentable {}
Using
@DictionaryStorage
type:let data = """ { "name": "John Doe", "age": 30, "gender": "male" }, """.data(using: .utf8)! let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] var person = Person(json!) print(person.name) // "John Doe" print(person.age) // 30 person.age += 1 print(person.rawDictionary) // Preserve unknown fields such as `gender` // ["name": "John Doe", "age": 31, "gender": "male"]
You can specify a different key name than the property name by using
@DictionaryStorageProperty
:@DictionaryStorage struct Person { @DictionaryStorageProperty("person_name") var name: String = "" }
The macro expands the code to:
struct Person { var name: String = "" { get { _storage["person_name"] as? String ?? "" } set { _storage["person_name"] = newValue } } ... }
If you need a custom initializer, say you need to do data migration, you can write your own initializer and DictionaryStorage won't generate the function. Don't forget to set
_storage
!@DictionaryStorage struct Person { var firstName: String = "" var lastName: String = "" init(_ dictionary: [String: Any]) { self._storage = dictionary // Migrating from old version... if let name = dictionary["name"] { firstName = name } } }
This package includes the following macros:
Attach to a type you want to be DictionaryRepresentable
.
-
@DictionaryStorage
Expand non-private stored properties to computed properties. Also add an initializer with[String: Any]
as well as a read-only property to access the dictionary. -
@DictionaryStorage(.equatable)
Enables the attached type to conform toEquatable
. Note, that the `==`` method only compares the known properties and does not compare private properties nor already computed properties before the macro applied.@DictionaryStorage(.equatable) struct Person { let name: String = "" var age: Int? }
The macro expands to:
struct Person { let name: String = "" { ... } var age: Int? { ... } ... static func == (lhs: Person, rhs: Person) -> Bool { lhs.name == rhs.name && lhs.age == rhs.age } } ... extension Person: Equatable {}
-
@DictionaryStorage(.hashable)
Enables the attached type to conform toHashable
.
Use this macro on property declarations of a type that is annotated with @DictionaryStorage
.
@DictionaryStorageProperty("custom_name")
Specifies "custom_name" as the key for the dictionary.
Applying this macro to an enum ensures it can be represented as a raw string. This is particularly useful in conjunction with @DictionaryStorage
.
-
@StringRawRepresentation
Make the attached enum to be a string raw representable.@StringRawRepresentation public enum InputType { case text case email case password @CustomName("select-one") case option case unknown(String) }
The macro expands to:
public enum InputType { ... public var rawValue: String { switch self { case .text: return "text" case .email: return "email" case .password: return "password" case .option: return "select-one" case .unknown(let value): return value } } public init?(rawValue: String) { switch rawValue { case "text": self = .text case "email": self = .email case "password": self = .password case "select-one": self = .option default: self = .unknown(rawValue) } } } extension InputType: RawRepresentable { } extension InputType: Equatable { }
Use this macro to set a custom name for an enum that is annotated with @StringRawRepresentation
.
@CustomName("custom_name")
Specifies"custom_name"
as a raw value of the enum case this macro is attached to.
By default, DictionaryStorage supports primitive types that JSONSerialization
supports, which are Bool
, Int
, String
, Double
and their variants such as Int8
, Float
, etc. DictionaryStorage also supports array of those primitives as well other instances DictionaryStorage and arrays of DictionaryStorage.
By writing a custom encoder/decoder, you can use non-primitive types for DictionaryStorage.
Suppose your data has UUID strings and you want to encode/decode to Foundation's UUID
type instead of String
, you can write a custom encoder/decoder as an extension of DictionaryStorage
:
extension DictionaryStorage {
static public func decode(_ type: UUID.Type, value: Any?) -> UUID? {
if let value = value as? String {
return UUID(uuidString: value)
} else {
return nil
}
}
static func encode(_ value: UUID?) -> Any? {
return value?.uuidString
}
}
Now you can use @DictionaryStorage
to a type like this:
@DictionaryStorage
struct Person: Identifiable {
var id: UUID = UUID()
var name: String = ""
var age: Int?
}
The macro expands to:
struct Person {
var id: UUID = UUID() {
get {
guard let value = _storage["id"] else {
return UUID()
}
return DictionaryStorage.decode(UUID.self, value: value) ?? UUID()
}
set {
_storage["id"] = DictionaryStorage.encode(newValue)
}
}
var name: String = "" { ... }
var age: Int? { ... }
}
This works for types that consume a single entry in the backed dictionary such as UUID
, Date
, URL
, Data
and so on. See CustomTypes.swift for a sample implementation.
For types requres multiple entries in the dictionary and you can't use @DictionaryStorage
macro for the type, use DictionaryRepresentable
protocol described below.
DictionaryRepresentable
is a protocol that is added to @DictionaryStorage
attached type:
public protocol DictionaryRepresentable {
init(_ dictionary: [String: Any])
var rawDictionary: [String: Any] { get }
}
Manually conforming this protocol to a custom type then @DictionaryStorage
will be able to handle the custom type.
Say, you need to support a heterogeneous array that holds several different types of @DictionaryStorage
backed types:
@DictionaryStorage
struct Web {
var type: Usage.UsageType = .web
var name: String = ""
var url: String = ""
}
@DictionaryStorage
struct App {
var type: Usage.UsageType = .app
var name: String = ""
var bundleId: String = ""
}
// `Usage` can be `Web`, `App`, or possibly something else in a future version.
struct Usage: DictionaryRepresentable {
enum UsageType: String {
case web
case app
case unknown
}
enum Value {
case web(Web)
case app(App)
case unknown([String: Any])
}
var value: Value
var _storage: [String: Any]
// MARK: - DictionaryRepresentable
var rawDictionary: [String: Any] {
_storage
}
init(_ dictionary: [String: Any]) {
self._storage = dictionary
if let typeString = dictionary["type"] as? String,
let type = UsageType(rawValue: typeString) {
self.type = type
switch type {
case .web:
value = .web(Web(dictionary))
case .app:
value = .app(App(dictionary))
case .unknown:
value = .unknown(dictionary)
}
} else {
self.type = .unknown
value = .unknown(dictionary)
}
}
}
// Now you can use `Usage` inside of another `@DictionaryStorage` type.
@DictionaryStorage
struct Object {
var usages: [Usage] = []
}
let data =
"""
{
"usages" : [
{
"type": "web",
"name": "Example.com",
"url": "https://www.example.com",
},
{
"type": "web",
"name": "Apple",
"url": "https://www.apple.com",
},
{
"type": "app",
"name": "Chrome",
"bundleId": "com.google.chrome",
},
{
"type": "something_else",
"name": "Something Else",
},
]
}
""".data(using: .utf8)!
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let object = Object(json!)
if case let .web(web) = object.usages[0].value {
print(web.type == .web) // true
print(web.name) // "Example.com"
print(web.url) // "https://www.example.com"
}
If you want your type to be capable of both reading from a dictionary and constructing one, use @DictionaryStorage
with @MemberwiseInit
macro.
@DictionaryStorage
struct Person {
var name: String = ""
}
// This works but not great
var person = Person([:])
person.name = "John Doe"
let dict = person.rawDictionary // ["name": "John Doe"]
With @MemberwiseInit
:
@MemberwiseInit
@DictionaryStorage
struct Person {
var name: String = ""
}
The macro expands to:
struct Person {
var name: String = "" {
...
}
internal init(
name: String = ""
) {
self.name = name
}
}
Now you can write like this:
let person = Person(name: "John Doe")
let dict = person.rawDictionary // ["name": "John Doe"]
@DictionaryStorage
is available under the MIT license. See the LICENSE file for more info.