SoFunction
Updated on 2025-04-11

Detailed explanation of the illusion types in Swift

Preface

Fuzzy data is arguably one of the most common sources of errors and problems in general applications. While Swift helps us avoid many vague sources through its powerful type system and well-established compiler—there is always a risk as long as we cannot guarantee that a certain data always meets our requirements at compile time, and we end up in a vague or unpredictable state.

This week, let's take a look at a technology that allows us to use Swift's type system to perform more kinds of data validation at compile time—dispel more potential sources of ambiguity and help us keep type-safe throughout our code base—by usingPhantom Type(phantom types)。

Well defined, but still vague

For example, suppose we are developing a text editor, although it initially supports only plain text files – over time, we have also added support for editing HTML documents, as well as PDF previews.

To be able to reuse as much of our original document processing code as possible, we continue to use the same ones as we did at the beginningDocumentModel – Just now it has obtained aFormatProperties, tell us what kind of documentation we are working on:

struct Document {
    enum Format {
        case text
        case html
        case pdf
    }
    var format: Format
    var data: Data
    var modificationDate: Date
    var author: Author
}

It is certainly a good thing to be able to avoid code duplication, and enumeration is when we are dealing with different formats or variants of a modelModeling under normal circumstancesa good way, but the above setting actually ends up causing quite a bit of ambiguity.

For example, we might have some APIs that make sense only when calling a document of a given format - like this function that opens a text editor, which assumes anything passed into itDocumentAll text documents:

func openTextEditor(for document: Document) {
    let text = String(decoding: , as: )
    let editor = TextEditor(text: text)
    ...
}

While it's not the end of the world if we accidentally pass an HTML document to the above function (HTML is just text after all), trying to open a PDF this way will most likely result in rendering something completely incomprehensible, our text editing features won't work, and our application may even eventually crash.

We keep having the same problem when writing any other specific format code, for example, if we want to improve the user experience of editing HTML documents by implementing a parser and a dedicated editor:

func openHTMLEditor(for document: Document) {
    // Just like the function we used for text editing above,    // This function assumes that it is always passed to the HTML document.    let parser = HTMLParser()
    let html = ()
    let editor = HTMLEditor(html: html)
    ...
}

A preliminary idea on how to solve the above problem might be to write a wrapper function, switch to the format of the passed document, and then open the correct editor for each case. However, while this works well for text and HTML documents, since PDF documents are not editable in our application – when encountering PDF, we will be forced to throw an error, trigger an assertion, orOther methods failed

func openEditor(for document: Document) {
    switch  {
    case .text:
        openTextEditor(for: document)
    case .html:
        openHTMLEditor(for: document)
    case .pdf:
        assertionFailure("Cannot edit PDF documents")
    }
}

The above situation is not very good because it requires us as developers to always track the file types we process in any given code path, and any mistakes we may make can only be discovered at runtime - the compiler simply does not have enough information to do this kind of check at compile time.

So while our "Document" model may seem very elegant and perfect at first glance, it turns out that it is not exactly the right solution to the situation at hand.

It looks like we need a deal!

One way to solve the above problem is toDocumentBecome a protocol instead of being a concrete type, putting all its properties (exceptformat) are all required:

protocol Document {
    var data: Data { get }
    var modificationDate: Date { get }
    var author: Author { get }
}

With the above changes, we can now implement specialized types for each of our three document formats and make them all conform to our new documentation protocol—such as this:

struct TextDocument: Document {
    var data: Data
    var modificationDate: Date
    var author: Author
}

The advantage of the above method is that it allows us to achieve both theDocumentA common function to perform operations, and can also implement specific APIs that only accept certain specific types:

// This function can save any file.// So it accepts any new documentation protocol that complies with our new documentation.func save(_ document: Document) {
    ...
}

// We can only pass text files to our functions now,// Open a text editor.func openTextEditor(for document: TextDocument) {
    ...
}

What we did above is basically turning the checks we did before at runtime to validation at compile time - because the compiler is now able to check if we always pass files in the correct format to each of our APIs, which is a big step forward.

However, by performing the above changes, we alsoLost the advantages of our initial implementation - code reuse. Since we now use a protocol to represent all document formats, we will need to write a completely repetitive model implementation for each of our three document types, as well as support for any other formats we may add in the future.

Introduce illusion types

If we can find a way to reuse the same for all formatsDocumentWouldn't it be great that the model can verify our specific format code at compile time? It turns out that a line of code we had before actually gave us a hint to achieve this:

let text = String(decoding: , as: )

WhenDataConvert toStringWhen, like we did above, we pass the encoding we want the string to be decoded by passing a reference to the type itself—in this case UTF8. This is really fun. If we go deeper, we will find that the Swift standard library defines the UTF8 type we mentioned above as a case-free enum in another namespace-like enum calledUnicode

enum Unicode {
    enum UTF8 {}
    ...
}
typealias UTF8 = Unicode.UTF8

Please note that if you look at itUTF8The actual implementation of the type, which does contain a private case, exists just for backward compatibility with Swift 3.

What we see here is a kind calledTechnique of illusion type—When a type is used as a tag, rather than instantiated to represent a value or object. In fact, since none of the above enums are publicly available, they cannot even be instantiated!

Let's see if the same technology can be used to solve our problemDocumentDilemma. We will firstDocumentRestore to a structure, but this time we will delete itformatattribute (and related enums), and turn it into a override anythingFormatGenerics of types - for example:

struct Document<Format> {
    var data: Data
    var modificationDate: Date
    var author: Author
}

Standard libraryUnicodeInspired by enumeration and its various encodings, we will define a similar enum—DocumentFormat——As the namespace for three case-free enumerations, each format has one:

enum DocumentFormat {
    enum Text {}
    enum HTML {}
    enum PDF {}
}

Note that no protocol is involved here - any type can be used as format, because likeStringLike its various encodings, we will only use the document'sFormatTypes are compile-time tags. This will allow us to write out our specific format API like this:

func openTextEditor(for document: Document<>) {
    ...
}
func openHTMLEditor(for document: Document<>) {
    ...
}
func openPreview(for document: Document<>) {
    ...
}

Of course, we can still write common code that does not require any specific format. For example, here we can put the previous onesaveThe API becomes a completely general function:

func save<F>(_ document: Document<F>) {
    ...
}

However, always inputDocument<>It is quite tedious to quote a text document, so let's define shorthand for each format as well using type alias. This will give us a nice, semantic name without any duplicate code:

typealias TextDocument = Document<>
typealias HTMLDocument = Document<>
typealias PDFDocument = Document<>

When it comes to extensions in specific formats, the illusion type is indeed shining, and now you can use Swift's powerful generic system directly andGeneric ConstraintsTo achieve it. For example, we can use a generatorNSAttributedStringMethod to extend all text documents:

extension Document where Format ==  {
    func makeAttributedString(withFont font: UIFont) -> NSAttributedString {
        let string = String(decoding: data, as: )

        return NSAttributedString(string: string, attributes: [
            .font: font
        ])
    }
}

Since our illusion types are just ordinary types at the end - we can also make them comply with protocols and use these protocols as generic constraints. For example, we can make some of ourDocumentFormatType compliancePrintableProtocols, we can then use these protocols as constraints in the print code. There are a lot of possibilities here.

A standard model

At first, the illusion type may look a bit "out of place" in Swift. However, while Swift does not provide top-notch support for illusion types like more pure functional languages ​​like Haskell, this pattern can be found in many different places in the Standard Library and Apple's SDK.

For example,FoundationofMeasurementThe API uses phantom types to ensure type safety when passing various measurements—such as degrees, length, and weight:

let meters = Measurement<UnitLength>(value: 5, unit: .meters)
let degrees = Measurement<UnitAngle>(value: 90, unit: .degrees)

By using the Phantom type, the two measurements above cannot be mixed because the unit each value is encoded into the type of that value. This prevents us from accidentally passing a length to a function that accepts angles and vice versa - just like we did before preventing the document format from being confused.

in conclusion

Using illusion types is a very powerful technique that allows us to leverage the type system to validate different variants of a specific value. While using illusion types often makes the API more verbose, it does come with the complexity of generics—when dealing with different formats and variants, it allows us to reduce our dependence on runtime checks, and let the compiler perform these checks.

Just like generics in general, I think it's important to first carefully evaluate the current situation before deploying the illusion type. Just like our initialDocumentThe model is not the right choice for the task at hand, and although it is well structured, the illusion type can make simple setup more complicated if deployed in the wrong situation. As usual, it boils down to choosing the right tool for the job.

This is all about this article about the illusion type in Swift. For more related content on Swift, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!