Goodbye Raw Strings, Hello swift-tagged

There is a subtle but common problem in Swift development: relying on raw types like String or UUID for identifiers leads to fragile code where values can be accidentally swapped or misused without the compiler noticing. By explaining how swift-tagged introduces zero-cost, strongly typed wrappers, I wanted to show you how other iOS developers how to prevent whole categories of bugs at compile time, while keeping APIs clean, expressive, and fully compatible with Codable, Hashable, and other Swift protocols. It’s a practical, easy-to-adopt tool that makes codebases more robust, and many developers may not even realize how much type safety they’re leaving on the table until they see a real-world example.

The problem

The following code is syntactically correct, but semantically wrong.

    struct UserRaw { let id: UUID }
    struct ProductRaw { let id: UUID }

    func registerPurchaseRaw(userID: UUID, productID: UUID) {
        log("✅ Purchase registered (RAW): user=\(userID) product=\(productID)")
    }

    func demoRaw() {
        log("— RAW demo —")
        let rawUser = UserRaw(id: UUID())
        let rawProduct = ProductRaw(id: UUID())

        // ❌ Compiles, BUT CODE SEMANTICALLY IS WRONG (crossed arguments)
        registerPurchaseRaw(userID: rawProduct.id, productID: rawUser.id)
        log("")
    }

This code defines two structs, UserRaw and ProductRaw, each holding an id of type UUID, and a function registerPurchaseRaw(userID:productID:) that logs a message about a registered purchase. In demoRaw(), it creates a user and a product, then mistakenly calls registerPurchaseRaw with the arguments swapped (userID is given the product’s ID and productID is given the user’s ID). The key issue is that both IDs are plain UUIDs, so the compiler cannot distinguish between them—this compiles without error even though it is logically wrong. The problem is a lack of type safety, which can lead to subtle bugs where mismatched identifiers are passed to functions unnoticed until runtime.

swift-tagged library

The swift-tagged library is a lightweight Swift package from Point-Free that lets you create strongly typed wrappers around existing primitive types like String, Int, or UUID. Instead of passing around raw values (e.g. using plain UUID for both UserID and OrderID), you can “tag” them with distinct types so the compiler enforces correct usage—preventing accidental mix-ups that would otherwise compile but cause logic bugs. It’s a zero-cost abstraction, meaning it adds no runtime overhead, and it integrates seamlessly with protocols like Codable, Hashable, and Equatable. In practice, swift-tagged helps make Swift code more expressive, self-documenting, and safer, especially when modeling identifiers and domain-specific values in iOS or server-side Swift apps.

This is new proposal with swift-tagged library:

    struct UserTag {}
    struct ProductTag {}

    typealias UserID = Tagged<UserTag, UUID>
    typealias ProductID = Tagged<ProductTag, UUID>

    struct User {
        let id: UserID
    }
    struct Product {
        let id: ProductID
    }

    func registerPurchase(userID: UserID, productID: ProductID) {
        log("✅ Purchase registered (Tagged): user=\(userID) product=\(productID)")
    }

    func demoTagged() {
        log("— Tagged demo —")
        let user = User(id: UserID(UUID()))
        let product = Product(id: ProductID(UUID()))
        registerPurchase(userID: user.id, productID: product.id)

        // ❌ This no longer compiles (type mismatch): // registerPurchase(userID: product.id, productID: user.id
        registerPurchase(userID: product.id, productID: user.id)
        log("")
    }

Now at compile time, semantic error is being detected:

Codable types

swift-tagged works with Codable types because its Tagged wrapper is designed to forward encoding and decoding responsibilities to the underlying raw type (such as String, Int, or UUID) that already conforms to Codable. This means when you use a Tagged<User, UUID> as a property in a model, Swift’s Codable machinery simply encodes or decodes the inner UUID as usual, while still preserving the type-safe distinction at compile time. As a result, you get the safety of strongly typed identifiers without having to write custom Codable implementations or change how your models interact with JSON or other encoded data.

        log("— Codable + JSON —")

        let user = User(id: UserID(UUID()))
        let product = Product(id: ProductID(UUID()))
        let request = PurchaseRequest(userID: user.id, productID: product.id)

        // Encode → JSON
        do {
            let encoder = JSONEncoder()
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            let jsonData = try encoder.encode(request)
            if let jsonString = String(data: jsonData, encoding: .utf8) {
                log("📤 JSON sent to server:")
                log(jsonString)
            }
        } catch {
            log("Encoding error:", error.localizedDescription)
        }

        // Decode ← JSON
        let jsonInput = """
        {
            "userID": "\(UUID())",
            "productID": "\(UUID())"
        }
        """.data(using: .utf8)!

        do {
            let decoded = try JSONDecoder().decode(PurchaseRequest.self, from: jsonInput)
            log("📥 JSON received and decoded to Swift struct:")
            log("userID: \(decoded.userID)")
            log("productID: \(decoded.productID)")
        } catch {
            log("Decoding error:", error.localizedDescription)
        }

        log("")

This code demonstrates how a PurchaseRequest that uses swift-tagged identifiers can be seamlessly encoded to and decoded from JSON. First, it creates a User and a Product, builds a PurchaseRequest with their strongly typed IDs, and then uses JSONEncoder to serialize it into a nicely formatted JSON string, simulating data being sent to a server. Next, it constructs a JSON string containing new random UUIDs for userID and productID, converts it to Data, and decodes it back into a PurchaseRequest instance with JSONDecoder. The output shows that although the code benefits from type safety at compile time, the wrapped values still encode and decode just like plain UUIDs, ensuring compatibility with standard JSON APIs.

Hashable

Yes—swift-tagged works with Hashable types because its Tagged<Tag, RawValue> wrapper automatically conforms to Hashable whenever the underlying RawValue does (e.g., UUID, String, Int). This means tagged IDs like UserID or ProductID can be used directly in Sets to enforce uniqueness, as keys in Dictionarys, or inside other Hashable models without extra boilerplate. In practice, you get the benefits of type safety and domain clarity while still leveraging Swift’s built-in hashing behavior, all with zero runtime overhead.

        // ---------------------------------------------------------
        // 🔢 4. Using swift-tagged with Hashable collections
        // ---------------------------------------------------------

        // Sets of tagged IDs
        let user = User(id: UserID(UUID()))
        var seenUsers = Set<UserID>()
        seenUsers.insert(user.id)                 // from earlier code
        seenUsers.insert(UserID(UUID()))          // a different user
        seenUsers.insert(user.id)                 // duplicate; Set ignores it

        log("👥 Seen users (unique count): \(seenUsers.count)")

        // Dictionaries with tagged IDs as keys
        let product = Product(id: ProductID(UUID()))
        var productStock: [ProductID: Int] = [:]
        productStock[product.id] = 10             // from earlier code
        let anotherProductID = ProductID(UUID())
        productStock[anotherProductID] = 5

        log("📦 Product stock:")
        for (pid, qty) in productStock {
            log(" - \(pid): \(qty)")
        }

        // Using tagged IDs inside Hashable models
        struct CartItem: Hashable {
            let productID: ProductID
            let quantity: Int
        }

        var cart = Set<CartItem>()
        cart.insert(CartItem(productID: product.id, quantity: 1))
        cart.insert(CartItem(productID: product.id, quantity: 1)) // duplicate CartItem; Set ignores it
        cart.insert(CartItem(productID: product.id, quantity: 2)) // different (quantity), so distinct
        cart.insert(CartItem(productID: anotherProductID, quantity: 1))

        log("🛒 Cart unique items: \(cart.count)")

This code shows how swift-tagged identifiers can be used in Swift collections that rely on Hashable. First, it creates a Set<UserID> and demonstrates that inserting the same tagged ID twice does not create duplicates, while a new tagged ID is treated as unique. Next, it builds a dictionary [ProductID: Int] to associate stock counts with product IDs, proving that tagged IDs work seamlessly as dictionary keys. Finally, it defines a CartItem struct containing a tagged ProductID and makes it Hashable, then inserts several items into a Set<CartItem>—duplicates with identical values collapse into one entry, while items that differ in quantity or product ID remain distinct. Overall, the snippet illustrates how swift-tagged provides type-safe IDs that integrate naturally with Set and Dictionary without extra work.

Conclusions

Swift’s capabilities are growing year by year. I’m not a big fan of using third-party libraries, but to avoid the problem presented here I would highly recommend this one.


You can find the source code from this example in the following GitHub repository link.

References

Copyright © 2024-2025 JaviOS. All rights reserved