Swift is a type-safe, statically typed language, meaning that each value has a type and the compiler performs type checking at compile time. Your code won't compile if it contains type errors. The benefit is that common bugs are caught early by the compiler. Swift's type system is strict and that also has its drawbacks. It makes the language less dynamic compared to other languages you may be familiar with, such as Ruby and JavaScript.
To work around the limitations of Swift's type system, it is at times necessary to hide the type of a value. Hiding or erasing the type of a value is better known as type erasure. Like dependency injection, type erasure may feel like a daunting concept at first, but it isn't that difficult to understand.
Type erasure comes in a variety of forms and it is likely that you have used type erasure in some of your projects, possibly without knowing it. In this series, you learn what type erasure is, why it is a useful pattern, and we take a look at a few examples of type erasure in Swift.
What Is Type Erasure?
As the name suggests, type erasure simply means hiding or erasing the type of a value. There are several ways to accomplish that and we take a look at several examples in this series.
As I mentioned earlier, Swift is a type-safe language. Each value has a type. Even if you use type erasure, the compiler is still able to access the type of the value whose type is erased. Type safety is a fundamental concept of the language and type erase doesn't change that. Keep that in mind.
Hiding a Type Behind a Protocol
While type erasure is a simple concept, it can complicate your code quite a bit. I want to start simple with an example that illustrates how you can hide a value's type. The example we look at isn't considered a form of type erasure, but it illustrates the concept of hiding the type of a value and why that can be useful.
Fire up Xcode and create a playground. Add an import statement for the Foundation framework at the top.
import Foundation
We declare three structs, Photo
, Letter
, and Printer
. The Photo
struct has a property, image
, of type Data
and the Letter
struct has a property text
of type String
. The Printer
struct can print photos and letters. It defines a print(_:)
method that accepts a Photo
object and it defines a print(_:)
method that accepts a Letter
object.
import Foundation
struct Photo {
let image: Data
}
struct Letter {
let text: String
}
struct Printer {
func print(_ photo: Photo) {
}
func print(_ letter: Letter) {
}
}
Let's create a Printer
object and print a photo and a letter.
let printer = Printer()
printer.print(Photo(image: Data()))
printer.print(Letter(text: "My Letter"))
The implementation has a few flaws, though. To add support for printing postcards, we need to extend the Printer
struct with a method that accepts a Postcard
object. This may be acceptable for now, but I hope you agree that this isn't a scalable solution. The implementation also suffers from tight coupling. The Printer
struct is tightly coupled to the Photo
and Letter
structs. Should a printer know about photos and letters? It should only know how to print them. Right?
Let's clean up the implementation with a protocol. We define a protocol with name Printable
. The protocol defines a single property, data
, of type Data
.
protocol Printable {
var data: Data { get }
}
The printer should be able to print a Printable
object so we define a print(_:)
method that accepts a Printable
object. It asks the Printable
object for the data that needs to be printed through the protocol's data
property.
struct Printer {
func print(_ photo: Photo) {
}
func print(_ letter: Letter) {
}
func print(_ printable: Printable) {
}
}
We can decouple Printer
from Photo
and Letter
by conforming Photo
and Letter
to Printable
. The only requirement is that Photo
and Letter
declare a property with name data
of type Data
.
struct Photo: Printable {
let image: Data
var data: Data {
image
}
}
struct Letter: Printable {
let text: String
var data: Data {
text.data(using: .utf8) ?? Data()
}
}
Because Photo
and Letter
conform to Printable
, we can remove the print(_:)
methods that accept a Photo
or a Letter
object.
struct Printer {
func print(_ printable: Printable) {
}
}
The Printer
object can still print photos and letters.
let printer = Printer()
printer.print(Photo(image: Data()))
printer.print(Letter(text: "My Letter"))
We created the Printable
protocol to hide the type of the value that we pass to the print(_:)
method. This pattern is better known as protocol-oriented-programming. While it isn't considered a form of type erasure, it clearly illustrates what hiding a value's type means and what the benefits are of doing so.
The only requirement of the print(_:)
method is that the object that needs to be printed conforms to the Printable
protocol. The solution we implemented promotes decoupling. The Printer
struct is no longer tightly coupled to Photo
and Letter
. It only cares that the object that is passed to the print(_:)
method conforms to the Printable
protocol.
Adding support for postcards becomes trivial. We don't need to make changes to the Printer
struct. We define a struct with name Postcard
that conforms to the Printable
protocol. The fact that we don't need to make changes to Printer
to add support for postcards confirms that the Printer
struct isn't tightly coupled to Postcard
.
struct Postcard: Printable {
let text: String
var data: Data {
text.data(using: .utf8) ?? Data()
}
}
let printer = Printer()
printer.print(Photo(image: Data()))
printer.print(Letter(text: "My Letter"))
printer.print(Postcard(text: "My Postcard"))
What's Next?
Even though Swift's type system is less flexible than that of a loosely typed language, the language supports patterns, such as type erasure and protocol-oriented programming, that allow you to write flexible code. In the next episode, we take a look at type erasure in action and what problems it can solve.