qmoya/swift-tap
`Tap` is a tiny library (11 LOC!) that lets you configure instances after
The problem
The ergonomics of Swift’s structs are excellent. Picture this one:
struct Person {
let name: String
let age: Int
}You get one initializer, Person(name:age:) without extra work, and even more initializers if you turn the lets into vars and provide default values. Imagine this Person instead:
struct Person {
var name: String = ""
var age: Int = 0
}Then, besides Person(name:age:), you’ll also get Person(), Person(name:), and Person(age:) without having to write any extra code. This automated synthesis shines when you want some stand-in instance.
Should you want to add another field with a default value to the struct, say phoneNumber,
struct Person {
var name: String = ""
var age: Int = 0
var phoneNumber: String = ""
}then the rest of your program (remember when we called apps “programs”?) will keep on working without modification. Good code is malleable — easy to adapt to new requirements.
Sadly, all of this breaks when you want to consume a struct from one module into another. (This happens to me a lot since I’m a fan of The Composable Architecture, and I like splitting my app into isolated feature-based packages.)
Under these circumstances, you need to declare a public initializer in the struct you want to use.
struct Person {
init(name: String = "", age: Int = 0) {
self.name = name
self.age = age
}
var name: String
var age: Int
}Code is now double as long! Even worse, let’s say we add a phone number:
public struct Person {
public init(name: String = "", age: Int = 0, phoneNumber: String = "") {
self.name = name
self.age = age
self.phoneNumber = phoneNumber
}
public var name: String
public var age: Int
public var phoneNumber: String
}In order not to break existing clients of Person, we had to modify three different lines, compared to the one-line change we did do above.
Ergonomics improve a lot if you use a parameter-less init and configure the instance a posteriori.
public struct Person {
public init() {}
public var name: String = ""
public var age: Int = 0
}
var john = Person()
john.name = "John"
john.age = 41Now, adding a new field will have a more manageable ripple effect.
However, I’d argue the code is weaker now. We’ve detached initialization from configuration, so it doesn’t reveal intention as clearly as before. Plus, it may be semantically incorrect: now john is forcefully a var, regardless of whether we want to mutate it afterward or not.
The solution
Tap fixes this by providing you with a protocol, Tappable, that allows you to do this:
public struct Person: Tappable {
public init() {}
public var name: String = ""
public var age: Int = 0
}
let john = Person().tap { john in
john.name = "John"
john.age = 41
}Now you have structs that are easy to change across modules, and your code is as clear as if you were using your synthesized initializers.
Note that Tap also improves ergonomics in another way: whereas initializers require a specific argument order, configuration blocks don’t suffer from that constraint.
If your struct complies with DefaultConstructible (provided by us), you can be even more succinct by using .tap as a static function. I find this particularly useful when deriving structs in The Composable Architecture:
public struct PersonState: Equatable, Tappable, DefaultConstructible {
public init() {}
public var name: String = ""
public var age: Int = 0
}
struct AppState: Equatable {
public var name: String = ""
public var age: Int = 0
var personState: PersonState {
.tap { state in
state.name = name
state.age = age
}
}
}Package Metadata
Repository: qmoya/swift-tap
Default branch: main
README: README.md