├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md └── workflows │ └── ci.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources ├── Tagged │ ├── Tagged.swift │ └── UUID.swift ├── TaggedMoney │ └── TaggedMoney.swift └── TaggedTime │ └── TaggedTime.swift ├── Tagged.playground ├── Contents.swift └── contents.xcplayground └── Tests ├── TaggedMoneyTests └── TaggedMoneyTests.swift ├── TaggedTests └── TaggedTests.swift └── TaggedTimeTests └── TaggedTimeTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: macOS 14 | runs-on: macos-14 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Select Xcode 18 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 19 | - name: Run tests 20 | run: swift test 21 | 22 | linux: 23 | strategy: 24 | matrix: 25 | swift: 26 | - '5.10' 27 | name: Linux (Swift ${{ matrix.swift }}) 28 | runs-on: ubuntu-latest 29 | container: swift:${{ matrix.swift }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Run tests 33 | run: swift test --parallel 34 | - name: Run tests (release) 35 | run: swift test -c release --parallel 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | Packages/ 39 | Package.pins 40 | .build/ 41 | .derivedData/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots 68 | fastlane/test_output 69 | 70 | .DS_Store 71 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at mbw234@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Point-Free, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import Foundation 4 | import PackageDescription 5 | 6 | var package = Package( 7 | name: "swift-tagged", 8 | products: [ 9 | .library(name: "Tagged", targets: ["Tagged"]), 10 | .library(name: "TaggedMoney", targets: ["TaggedMoney"]), 11 | .library(name: "TaggedTime", targets: ["TaggedTime"]), 12 | ], 13 | targets: [ 14 | .target(name: "Tagged", dependencies: []), 15 | .testTarget(name: "TaggedTests", dependencies: ["Tagged"]), 16 | 17 | .target(name: "TaggedMoney", dependencies: ["Tagged"]), 18 | .testTarget(name: "TaggedMoneyTests", dependencies: ["TaggedMoney"]), 19 | 20 | .target(name: "TaggedTime", dependencies: ["Tagged"]), 21 | .testTarget(name: "TaggedTimeTests", dependencies: ["TaggedTime"]), 22 | ] 23 | ) 24 | 25 | for target in package.targets { 26 | target.swiftSettings = target.swiftSettings ?? [] 27 | target.swiftSettings!.append(contentsOf: [ 28 | .enableExperimentalFeature("StrictConcurrency") 29 | ]) 30 | } 31 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import Foundation 4 | import PackageDescription 5 | 6 | var package = Package( 7 | name: "swift-tagged", 8 | products: [ 9 | .library(name: "Tagged", targets: ["Tagged"]), 10 | .library(name: "TaggedMoney", targets: ["TaggedMoney"]), 11 | .library(name: "TaggedTime", targets: ["TaggedTime"]), 12 | ], 13 | targets: [ 14 | .target(name: "Tagged", dependencies: []), 15 | .testTarget(name: "TaggedTests", dependencies: ["Tagged"]), 16 | 17 | .target(name: "TaggedMoney", dependencies: ["Tagged"]), 18 | .testTarget(name: "TaggedMoneyTests", dependencies: ["TaggedMoney"]), 19 | 20 | .target(name: "TaggedTime", dependencies: ["Tagged"]), 21 | .testTarget(name: "TaggedTimeTests", dependencies: ["TaggedTime"]), 22 | ], 23 | swiftLanguageVersions: [.v6] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏷 Tagged 2 | 3 | [![CI](https://github.com/pointfreeco/swift-tagged/workflows/CI/badge.svg)](https://actions-badge.atrox.dev/pointfreeco/swift-tagged/goto) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-tagged%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-tagged) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-tagged%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-tagged) 6 | 7 | A wrapper type for safer, expressive code. 8 | 9 | ## Table of Contents 10 | 11 | - [Motivation](#motivation) 12 | - [The problem](#the-problem) 13 | - [The solution](#the-solution) 14 | - [Handling tag collisions](#handling-tag-collisions) 15 | - [Accessing raw values](#accessing-raw-values) 16 | - [Features](#features) 17 | - [Nanolibraries](#nanolibraries) 18 | - [FAQ](#faq) 19 | - [Installation](#installation) 20 | - [Interested in learning more?](#interested-in-learning-more) 21 | - [License](#license) 22 | 23 | ## Motivation 24 | 25 | We often work with types that are far too general or hold far too many values than what is necessary for our domain. Sometimes we just want to differentiate between two seemingly equivalent values at the type level. 26 | 27 | An email address is nothing but a `String`, but it should be restricted in the ways in which it can be used. And while a `User` id may be represented with an `Int`, it should be distinguishable from an `Int`-based `Subscription` id. 28 | 29 | Tagged can help solve serious runtime bugs at compile time by wrapping basic types in more specific contexts with ease. 30 | 31 | ## The problem 32 | 33 | Swift has an incredibly powerful type system, yet it's still common to model most data like this: 34 | 35 | ``` swift 36 | struct User { 37 | let id: Int 38 | let email: String 39 | let address: String 40 | let subscriptionId: Int? 41 | } 42 | 43 | struct Subscription { 44 | let id: Int 45 | } 46 | ``` 47 | 48 | We're modeling user and subscription ids using _the same type_, but our app logic shouldn't treat these values interchangeably! We might write a function to fetch a subscription: 49 | 50 | ``` swift 51 | func fetchSubscription(byId id: Int) -> Subscription? { 52 | return subscriptions.first(where: { $0.id == id }) 53 | } 54 | ``` 55 | 56 | Code like this is super common, but it allows for serious runtime bugs and security issues! The following compiles, runs, and even reads reasonably at a glance: 57 | 58 | ``` swift 59 | let subscription = fetchSubscription(byId: user.id) 60 | ``` 61 | 62 | This code will fail to find a user's subscription. Worse yet, if a user id and subscription id overlap, it will display the _wrong_ subscription to the _wrong_ user! It may even surface sensitive data like billing details! 63 | 64 | ## The solution 65 | 66 | We can use Tagged to succinctly differentiate types. 67 | 68 | ``` swift 69 | import Tagged 70 | 71 | struct User { 72 | let id: Id 73 | let email: String 74 | let address: String 75 | let subscriptionId: Subscription.Id? 76 | 77 | typealias Id = Tagged 78 | } 79 | 80 | struct Subscription { 81 | let id: Id 82 | 83 | typealias Id = Tagged 84 | } 85 | ``` 86 | 87 | Tagged depends on a generic "tag" parameter to make each type unique. Here we've used the container type to uniquely tag each id. 88 | 89 | We can now update `fetchSubscription` to take a `Subscription.Id` where it previously took any `Int`. 90 | 91 | ``` swift 92 | func fetchSubscription(byId id: Subscription.Id) -> Subscription? { 93 | return subscriptions.first(where: { $0.id == id }) 94 | } 95 | ``` 96 | 97 | And there's no chance we'll accidentally pass a user id where we expect a subscription id. 98 | 99 | ``` swift 100 | let subscription = fetchSubscription(byId: user.id) 101 | ``` 102 | 103 | > 🛑 Cannot convert value of type 'User.Id' (aka 'Tagged') to expected argument type 'Subscription.Id' (aka 'Tagged') 104 | 105 | We've prevented a couple serious bugs at compile time! 106 | 107 | There's another bug lurking in these types. We've written a function with the following signature: 108 | 109 | ``` swift 110 | sendWelcomeEmail(toAddress address: String) 111 | ``` 112 | 113 | It contains logic that sends an email to an email address. Unfortunately, it takes _any_ string as input. 114 | 115 | ``` swift 116 | sendWelcomeEmail(toAddress: user.address) 117 | ``` 118 | 119 | This compiles and runs, but `user.address` refers to our user's _billing_ address, _not_ their email! None of our users are getting welcome emails! Worse yet, calling this function with invalid data may cause server churn and crashes. 120 | 121 | Tagged again can save the day. 122 | 123 | ``` swift 124 | struct User { 125 | let id: Id 126 | let email: Email 127 | let address: String 128 | let subscriptionId: Subscription.Id? 129 | 130 | typealias Id = Tagged 131 | typealias Email = Tagged 132 | } 133 | ``` 134 | 135 | We can now update `sendWelcomeEmail` and have another compile time guarantee. 136 | 137 | ``` swift 138 | sendWelcomeEmail(toAddress address: Email) 139 | ``` 140 | 141 | ``` swift 142 | sendWelcomeEmail(toAddress: user.address) 143 | ``` 144 | 145 | > 🛑 Cannot convert value of type 'String' to expected argument type 'Email' (aka 'Tagged') 146 | 147 | ### Handling Tag Collisions 148 | 149 | What if we want to tag two string values within the same type? 150 | 151 | ``` swift 152 | struct User { 153 | let id: Id 154 | let email: Email 155 | let address: Address 156 | let subscriptionId: Subscription.Id? 157 | 158 | typealias Id = Tagged 159 | typealias Email = Tagged 160 | typealias Address = Tagged 161 | } 162 | ``` 163 | 164 | We shouldn't reuse `Tagged` because the compiler would treat `Email` and `Address` as the same type! We need a new tag, which means we need a new type. We can use any type, but an uninhabited enum is nestable and uninstantiable, which is perfect here. 165 | 166 | ``` swift 167 | struct User { 168 | let id: Id 169 | let email: Email 170 | let address: Address 171 | let subscriptionId: Subscription.Id? 172 | 173 | typealias Id = Tagged 174 | enum EmailTag {} 175 | typealias Email = Tagged 176 | enum AddressTag {} 177 | typealias Address = Tagged 178 | } 179 | ``` 180 | 181 | We've now distinguished `User.Email` and `User.Address` at the cost of an extra line per type, but things are documented very explicitly. 182 | 183 | If we want to save this extra line, we could instead take advantage of the fact that tuple labels are encoded in the type system and can be used to differentiate two seemingly equivalent tuple types. 184 | 185 | ``` swift 186 | struct User { 187 | let id: Id 188 | let email: Email 189 | let address: Address 190 | let subscriptionId: Subscription.Id? 191 | 192 | typealias Id = Tagged 193 | typealias Email = Tagged<(User, email: ()), String> 194 | typealias Address = Tagged<(User, address: ()), String> 195 | } 196 | ``` 197 | 198 | This may look a bit strange with the dangling `()`, but it's otherwise nice and succinct, and the type safety we get is more than worth it. 199 | 200 | ### Accessing Raw Values 201 | 202 | Tagged uses the same interface as `RawRepresentable` to expose its raw values, _via_ a `rawValue` property: 203 | 204 | ``` swift 205 | user.id.rawValue // Int 206 | ``` 207 | 208 | You can also manually instantiate tagged types using `init(rawValue:)`, though you can often avoid this using the [`Decodable`](#codable) and [`ExpressibleBy`-`Literal`](#expressibleby-literal) family of protocols. 209 | 210 | ## Features 211 | 212 | Tagged uses [conditional conformance](https://github.com/apple/swift-evolution/blob/master/proposals/0143-conditional-conformances.md), so you don't have to sacrifice expressiveness for safety. If the raw values are encodable or decodable, equatable, hashable, comparable, or expressible by literals, the tagged values follow suit. This means we can often avoid unnecessary (and potentially dangerous) [wrapping and unwrapping](#accessing-raw-values). 213 | 214 | ### Equatable 215 | 216 | A tagged type is automatically equatable if its raw value is equatable. We took advantage of this in [our example](#the-problem), above. 217 | 218 | ``` swift 219 | subscriptions.first(where: { $0.id == user.subscriptionId }) 220 | ``` 221 | 222 | ### Hashable 223 | 224 | We can use underlying hashability to create a set or lookup dictionary. 225 | 226 | ``` swift 227 | var userIds: Set = [] 228 | var users: [User.Id: User] = [:] 229 | ``` 230 | 231 | ### Comparable 232 | 233 | We can sort directly on a comparable tagged type. 234 | 235 | ``` swift 236 | userIds.sorted(by: <) 237 | users.values.sorted(by: { $0.email < $1.email }) 238 | ``` 239 | 240 | ### Codable 241 | 242 | Tagged types are as encodable and decodable as the types they wrap. 243 | 244 | ``` swift 245 | struct User: Decodable { 246 | let id: Id 247 | let email: Email 248 | let address: Address 249 | let subscriptionId: Subscription.Id? 250 | 251 | typealias Id = Tagged 252 | typealias Email = Tagged<(User, email: ()), String> 253 | typealias Address = Tagged<(User, address: ()), String> 254 | } 255 | 256 | JSONDecoder().decode(User.self, from: Data(""" 257 | { 258 | "id": 1, 259 | "email": "blob@pointfree.co", 260 | "address": "1 Blob Ln", 261 | "subscriptionId": null 262 | } 263 | """.utf8)) 264 | ``` 265 | 266 | ### ExpressiblyBy-Literal 267 | 268 | Tagged types inherit literal expressibility. This is helpful for working with constants, like instantiating test data. 269 | 270 | ``` swift 271 | User( 272 | id: 1, 273 | email: "blob@pointfree.co", 274 | address: "1 Blob Ln", 275 | subscriptionId: 1 276 | ) 277 | 278 | // vs. 279 | 280 | User( 281 | id: User.Id(rawValue: 1), 282 | email: User.Email(rawValue: "blob@pointfree.co"), 283 | address: User.Address(rawValue: "1 Blob Ln"), 284 | subscriptionId: Subscription.Id(rawValue: 1) 285 | ) 286 | ``` 287 | 288 | ### Numeric 289 | 290 | Numeric tagged types get mathematical operations for free! 291 | 292 | ``` swift 293 | struct Product { 294 | let amount: Cents 295 | 296 | typealias Cents = Tagged 297 | } 298 | ``` 299 | ``` swift 300 | let totalCents = products.reduce(0) { $0 + $1.amount } 301 | ``` 302 | 303 | ## Nanolibraries 304 | 305 | The `Tagged` library also comes with a few nanolibraries for handling common types in a type safe way. 306 | 307 | ### `TaggedTime` 308 | 309 | The API's we interact with often return timestamps in seconds or milliseconds measured from an epoch time. Keeping track of the units can be messy, either being done via documentation or by naming fields in a particular way, e.g. `publishedAtMs`. Mixing up the units on accident can lead to wildly inaccurate logic. 310 | 311 | By importing `TaggedTime` you will get access to two generic types, `Milliseconds` and `Seconds`, that allow the compiler to sort out the differences for you. You can use them in your models: 312 | 313 | ```swift 314 | struct BlogPost: Decodable { 315 | typealias Id = Tagged 316 | 317 | let id: Id 318 | let publishedAt: Seconds 319 | let title: String 320 | } 321 | ``` 322 | 323 | Now you have documentation of the unit in the type automatically, and you can never accidentally compare seconds to milliseconds: 324 | 325 | ```swift 326 | let futureTime: Milliseconds = 1528378451000 327 | 328 | breakingBlogPost.publishedAt < futureTime 329 | // 🛑 Binary operator '<' cannot be applied to operands of type 330 | // 'Tagged' and 'Tagged' 331 | 332 | breakingBlogPost.publishedAt.milliseconds < futureTime 333 | // ✅ true 334 | ``` 335 | 336 | Read more on our blog post: [Tagged Seconds and Milliseconds](https://www.pointfree.co/blog/posts/6-tagged-seconds-and-milliseconds). 337 | 338 | ### `TaggedMoney` 339 | 340 | API's can also send back money amounts in two standard units: whole dollar amounts or cents (1/100 of a dollar). Keeping track of this distinction can also be messy and error prone. 341 | 342 | Importing the `TaggedMoney` library gives you access to two generic types, `Dollars` and `Cents`, that give you compile-time guarantees in keeping the two units separate. 343 | 344 | ```swift 345 | struct Prize { 346 | let amount: Dollars 347 | let name: String 348 | } 349 | 350 | let moneyRaised: Cents = 50_000 351 | 352 | theBigPrize.amount < moneyRaised 353 | // 🛑 Binary operator '<' cannot be applied to operands of type 354 | // 'Tagged' and 'Tagged' 355 | 356 | theBigPrize.amount.cents < moneyRaised 357 | // ✅ true 358 | ``` 359 | 360 | It is important to note that these types do not encapsulate _currency_, but rather just the abstract notion of the whole and fractional unit of money. You will still need to track particular currencies, like USD, EUR, MXN, alongside these values. 361 | 362 | ## FAQ 363 | 364 | - **Why not use a type alias?** 365 | 366 | Type aliases are just that: aliases. A type alias can be used interchangeably with the original type and offers no additional safety or guarantees. 367 | 368 | - **Why not use `RawRepresentable`, or some other protocol?** 369 | 370 | Protocols like `RawRepresentable` are useful, but they can't be extended conditionally, so you miss out on all of Tagged's free [features](#features). Using a protocol means you need to manually opt each type into synthesizing `Equatable`, `Hashable`, `Decodable` and `Encodable`, and to achieve the same level of expressiveness as Tagged, you need to manually conform to other protocols, like `Comparable`, the `ExpressibleBy`-`Literal` family of protocols, and `Numeric`. That's a _lot_ of boilerplate you need to write or generate, but Tagged gives it to you for free! 371 | 372 | ## Installation 373 | 374 | You can add Tagged to an Xcode project by adding it as a package dependency. 375 | 376 | > https://github.com/pointfreeco/swift-tagged 377 | 378 | If you want to use Tagged in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to a `dependencies` clause in your `Package.swift`: 379 | 380 | ``` swift 381 | dependencies: [ 382 | .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0") 383 | ] 384 | ``` 385 | 386 | ## Interested in learning more? 387 | 388 | These concepts (and more) are explored thoroughly in [Point-Free](https://www.pointfree.co), a video series exploring functional programming and Swift hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis). 389 | 390 | Tagged was first explored in [Episode #12](https://www.pointfree.co/episodes/ep12-tagged): 391 | 392 | 393 | video poster image 394 | 395 | 396 | ## License 397 | 398 | All modules are released under the MIT license. See [LICENSE](LICENSE) for details. 399 | -------------------------------------------------------------------------------- /Sources/Tagged/Tagged.swift: -------------------------------------------------------------------------------- 1 | @dynamicMemberLookup 2 | public struct Tagged { 3 | public var rawValue: RawValue 4 | 5 | public init(rawValue: RawValue) { 6 | self.rawValue = rawValue 7 | } 8 | 9 | public init(_ rawValue: RawValue) { 10 | self.rawValue = rawValue 11 | } 12 | 13 | public subscript(dynamicMember keyPath: KeyPath) -> Subject { 14 | rawValue[keyPath: keyPath] 15 | } 16 | 17 | public func map( 18 | _ transform: (RawValue) throws -> NewValue 19 | ) rethrows -> Tagged { 20 | Tagged(rawValue: try transform(rawValue)) 21 | } 22 | 23 | public func coerced(to type: NewTag.Type) -> Tagged { 24 | unsafeBitCast(self, to: Tagged.self) 25 | } 26 | } 27 | 28 | extension Tagged: CustomStringConvertible { 29 | public var description: String { 30 | String(describing: rawValue) 31 | } 32 | } 33 | 34 | extension Tagged: RawRepresentable {} 35 | 36 | extension Tagged: CustomPlaygroundDisplayConvertible { 37 | public var playgroundDescription: Any { 38 | rawValue 39 | } 40 | } 41 | 42 | // MARK: - Conditional Conformances 43 | 44 | extension Tagged: Collection where RawValue: Collection { 45 | public func index(after i: RawValue.Index) -> RawValue.Index { 46 | rawValue.index(after: i) 47 | } 48 | 49 | public subscript(position: RawValue.Index) -> RawValue.Element { 50 | rawValue[position] 51 | } 52 | 53 | public var startIndex: RawValue.Index { 54 | rawValue.startIndex 55 | } 56 | 57 | public var endIndex: RawValue.Index { 58 | rawValue.endIndex 59 | } 60 | 61 | public consuming func makeIterator() -> RawValue.Iterator { 62 | rawValue.makeIterator() 63 | } 64 | } 65 | 66 | extension Tagged: Comparable where RawValue: Comparable { 67 | public static func < (lhs: Self, rhs: Self) -> Bool { 68 | lhs.rawValue < rhs.rawValue 69 | } 70 | } 71 | 72 | extension Tagged: Decodable where RawValue: Decodable { 73 | public init(from decoder: Decoder) throws { 74 | do { 75 | self.init(rawValue: try decoder.singleValueContainer().decode(RawValue.self)) 76 | } catch { 77 | self.init(rawValue: try RawValue(from: decoder)) 78 | } 79 | } 80 | } 81 | 82 | extension Tagged: Encodable where RawValue: Encodable { 83 | public func encode(to encoder: Encoder) throws { 84 | do { 85 | var container = encoder.singleValueContainer() 86 | try container.encode(self.rawValue) 87 | } catch { 88 | try self.rawValue.encode(to: encoder) 89 | } 90 | } 91 | } 92 | 93 | @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) 94 | extension Tagged: CodingKeyRepresentable where RawValue: CodingKeyRepresentable { 95 | public init?(codingKey: some CodingKey) { 96 | guard let rawValue = RawValue(codingKey: codingKey) 97 | else { return nil } 98 | self.init(rawValue: rawValue) 99 | } 100 | 101 | public var codingKey: CodingKey { 102 | self.rawValue.codingKey 103 | } 104 | } 105 | 106 | extension Tagged: Equatable where RawValue: Equatable {} 107 | 108 | extension Tagged: Error where RawValue: Error {} 109 | 110 | extension Tagged: Sendable where RawValue: Sendable {} 111 | 112 | extension Tagged: ExpressibleByBooleanLiteral where RawValue: ExpressibleByBooleanLiteral { 113 | public init(booleanLiteral value: RawValue.BooleanLiteralType) { 114 | self.init(rawValue: RawValue(booleanLiteral: value)) 115 | } 116 | } 117 | 118 | extension Tagged: ExpressibleByExtendedGraphemeClusterLiteral 119 | where RawValue: ExpressibleByExtendedGraphemeClusterLiteral { 120 | public init(extendedGraphemeClusterLiteral: RawValue.ExtendedGraphemeClusterLiteralType) { 121 | self.init(rawValue: RawValue(extendedGraphemeClusterLiteral: extendedGraphemeClusterLiteral)) 122 | } 123 | } 124 | 125 | extension Tagged: ExpressibleByFloatLiteral where RawValue: ExpressibleByFloatLiteral { 126 | public init(floatLiteral: RawValue.FloatLiteralType) { 127 | self.init(rawValue: RawValue(floatLiteral: floatLiteral)) 128 | } 129 | } 130 | 131 | extension Tagged: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral { 132 | public init(integerLiteral: RawValue.IntegerLiteralType) { 133 | self.init(rawValue: RawValue(integerLiteral: integerLiteral)) 134 | } 135 | } 136 | 137 | extension Tagged: ExpressibleByStringLiteral where RawValue: ExpressibleByStringLiteral { 138 | public init(stringLiteral: RawValue.StringLiteralType) { 139 | self.init(rawValue: RawValue(stringLiteral: stringLiteral)) 140 | } 141 | } 142 | 143 | extension Tagged: ExpressibleByStringInterpolation 144 | where RawValue: ExpressibleByStringInterpolation { 145 | public init(stringInterpolation: RawValue.StringInterpolation) { 146 | self.init(rawValue: RawValue(stringInterpolation: stringInterpolation)) 147 | } 148 | } 149 | 150 | extension Tagged: ExpressibleByUnicodeScalarLiteral 151 | where RawValue: ExpressibleByUnicodeScalarLiteral { 152 | public init(unicodeScalarLiteral: RawValue.UnicodeScalarLiteralType) { 153 | self.init(rawValue: RawValue(unicodeScalarLiteral: unicodeScalarLiteral)) 154 | } 155 | } 156 | 157 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 158 | extension Tagged: Identifiable where RawValue: Identifiable { 159 | public var id: RawValue.ID { 160 | rawValue.id 161 | } 162 | } 163 | 164 | extension Tagged: LosslessStringConvertible where RawValue: LosslessStringConvertible { 165 | public init?(_ description: String) { 166 | guard let rawValue = RawValue(description) else { return nil } 167 | self.init(rawValue: rawValue) 168 | } 169 | } 170 | 171 | extension Tagged: AdditiveArithmetic where RawValue: AdditiveArithmetic { 172 | public static var zero: Self { 173 | Self(rawValue: .zero) 174 | } 175 | 176 | public static func + (lhs: Self, rhs: Self) -> Self { 177 | Self(rawValue: lhs.rawValue + rhs.rawValue) 178 | } 179 | 180 | public static func += (lhs: inout Self, rhs: Self) { 181 | lhs.rawValue += rhs.rawValue 182 | } 183 | 184 | public static func - (lhs: Self, rhs: Self) -> Self { 185 | Self(rawValue: lhs.rawValue - rhs.rawValue) 186 | } 187 | 188 | public static func -= (lhs: inout Self, rhs: Self) { 189 | lhs.rawValue -= rhs.rawValue 190 | } 191 | } 192 | 193 | extension Tagged: Numeric where RawValue: Numeric { 194 | public init?(exactly source: some BinaryInteger) { 195 | guard let rawValue = RawValue(exactly: source) else { return nil } 196 | self.init(rawValue: rawValue) 197 | } 198 | 199 | public var magnitude: RawValue.Magnitude { 200 | rawValue.magnitude 201 | } 202 | 203 | public static func * (lhs: Self, rhs: Self) -> Self { 204 | Self(rawValue: lhs.rawValue * rhs.rawValue) 205 | } 206 | 207 | public static func *= (lhs: inout Self, rhs: Self) { 208 | lhs.rawValue *= rhs.rawValue 209 | } 210 | } 211 | 212 | extension Tagged: Hashable where RawValue: Hashable {} 213 | 214 | extension Tagged: SignedNumeric where RawValue: SignedNumeric {} 215 | 216 | extension Tagged: Sequence where RawValue: Sequence { 217 | public consuming func makeIterator() -> RawValue.Iterator { 218 | rawValue.makeIterator() 219 | } 220 | } 221 | 222 | extension Tagged: Strideable where RawValue: Strideable { 223 | public func distance(to other: Self) -> RawValue.Stride { 224 | rawValue.distance(to: other.rawValue) 225 | } 226 | 227 | public func advanced(by n: RawValue.Stride) -> Self { 228 | Tagged(rawValue: rawValue.advanced(by: n)) 229 | } 230 | } 231 | 232 | extension Tagged: ExpressibleByArrayLiteral where RawValue: ExpressibleByArrayLiteral { 233 | public init(arrayLiteral elements: RawValue.ArrayLiteralElement...) { 234 | let f = unsafeBitCast( 235 | RawValue.init(arrayLiteral:) as (RawValue.ArrayLiteralElement...) -> RawValue, 236 | to: (([RawValue.ArrayLiteralElement]) -> RawValue).self 237 | ) 238 | 239 | self.init(rawValue: f(elements)) 240 | } 241 | } 242 | 243 | extension Tagged: ExpressibleByDictionaryLiteral where RawValue: ExpressibleByDictionaryLiteral { 244 | public init(dictionaryLiteral elements: (RawValue.Key, RawValue.Value)...) { 245 | let f = unsafeBitCast( 246 | RawValue.init(dictionaryLiteral:) as ((RawValue.Key, RawValue.Value)...) -> RawValue, 247 | to: (([(Key, Value)]) -> RawValue).self 248 | ) 249 | 250 | self.init(rawValue: f(elements)) 251 | } 252 | } 253 | 254 | #if canImport(Foundation) 255 | import Foundation 256 | 257 | extension Tagged: LocalizedError where RawValue: Error { 258 | public var errorDescription: String? { 259 | rawValue.localizedDescription 260 | } 261 | 262 | public var failureReason: String? { 263 | (rawValue as? LocalizedError)?.failureReason 264 | } 265 | 266 | public var helpAnchor: String? { 267 | (rawValue as? LocalizedError)?.helpAnchor 268 | } 269 | 270 | public var recoverySuggestion: String? { 271 | (rawValue as? LocalizedError)?.recoverySuggestion 272 | } 273 | } 274 | #endif 275 | -------------------------------------------------------------------------------- /Sources/Tagged/UUID.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Tagged where RawValue == UUID { 4 | /// Generates a tagged UUID. 5 | /// 6 | /// Equivalent to `Tagged(UUID(())`. 7 | public init() { 8 | self.init(UUID()) 9 | } 10 | 11 | /// Creates a tagged UUID from a string representation. 12 | /// 13 | /// - Parameter string: The string representation of a UUID, such as 14 | /// `DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF`. 15 | public init?(uuidString string: String) { 16 | guard let uuid = UUID(uuidString: string) 17 | else { return nil } 18 | self.init(uuid) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/TaggedMoney/TaggedMoney.swift: -------------------------------------------------------------------------------- 1 | import Tagged 2 | 3 | public enum CentsTag {} 4 | 5 | /// A type that represents cents, i.e. one hundredth of a dollar. 6 | public typealias Cents = Tagged 7 | 8 | public enum DollarsTag {} 9 | 10 | /// A type that represents a dollar, i.e. a base unit of any currency. 11 | public typealias Dollars = Tagged 12 | 13 | extension Tagged where Tag == CentsTag, RawValue: BinaryFloatingPoint { 14 | /// Converts cents into dollars by dividing by 100. 15 | public var dollars: Dollars { 16 | return Dollars(rawValue: rawValue / 100) 17 | } 18 | } 19 | 20 | extension Tagged where Tag == DollarsTag, RawValue: Numeric { 21 | /// Converts dollars into cents by multiplying by 100. 22 | public var cents: Cents { 23 | return Cents(rawValue: rawValue * 100) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/TaggedTime/TaggedTime.swift: -------------------------------------------------------------------------------- 1 | import Dispatch 2 | import Foundation 3 | import Tagged 4 | 5 | public enum MillisecondsTag {} 6 | 7 | /// A type that represents milliseconds of time. 8 | public typealias Milliseconds = Tagged 9 | 10 | public enum SecondsTag {} 11 | 12 | /// A type that represents seconds of time. 13 | public typealias Seconds = Tagged 14 | 15 | extension Tagged where Tag == MillisecondsTag, RawValue: BinaryFloatingPoint { 16 | /// Converts milliseconds to seconds. 17 | public var seconds: Seconds { 18 | Seconds(rawValue: rawValue / 1000) 19 | } 20 | 21 | /// Converts milliseconds into `TimeInterval`, which is measured in seconds. 22 | public var timeInterval: TimeInterval { 23 | let seconds = seconds.rawValue 24 | return TimeInterval( 25 | sign: seconds.sign, 26 | exponentBitPattern: UInt(seconds.exponentBitPattern), 27 | significandBitPattern: UInt64(seconds.significandBitPattern) 28 | ) 29 | } 30 | 31 | /// Converts milliseconds into `Date`, which is measured in seconds. 32 | public var date: Date { 33 | Date(timeIntervalSince1970: timeInterval) 34 | } 35 | 36 | /// Converts milliseconds into `Duration`. 37 | @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) 38 | public var duration: Duration { 39 | .milliseconds(Double(rawValue)) 40 | } 41 | } 42 | 43 | extension Tagged where Tag == MillisecondsTag, RawValue: BinaryInteger { 44 | /// Converts milliseconds into `TimeInterval`, which is measured in seconds. 45 | public var timeInterval: TimeInterval { 46 | map(TimeInterval.init).timeInterval 47 | } 48 | 49 | /// Converts milliseconds into `DispatchTimeInterval`. 50 | public var dispatchTimeInterval: DispatchTimeInterval { 51 | .milliseconds(Int(rawValue)) 52 | } 53 | 54 | /// Converts milliseconds into `Date`, which is measured in seconds. 55 | public var date: Date { 56 | Date(timeIntervalSince1970: timeInterval) 57 | } 58 | 59 | /// Converts milliseconds into `Duration`. 60 | @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) 61 | public var duration: Duration { 62 | .milliseconds(rawValue) 63 | } 64 | } 65 | 66 | extension Tagged where Tag == SecondsTag, RawValue: Numeric { 67 | /// Converts seconds in milliseconds. 68 | public var milliseconds: Milliseconds { 69 | return Milliseconds(rawValue: rawValue * 1000) 70 | } 71 | } 72 | 73 | extension Tagged where Tag == SecondsTag, RawValue: BinaryInteger { 74 | /// Converts seconds into `TimeInterval`. 75 | public var timeInterval: TimeInterval { 76 | TimeInterval(Int64(rawValue)) 77 | } 78 | 79 | /// Converts seconds into `DispatchTimeInterval`. 80 | public var dispatchTimeInterval: DispatchTimeInterval { 81 | .seconds(Int(rawValue)) 82 | } 83 | 84 | /// Converts seconds into `Date`. 85 | public var date: Date { 86 | Date(timeIntervalSince1970: timeInterval) 87 | } 88 | 89 | /// Converts seconds into `Duration`. 90 | @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) 91 | public var duration: Duration { 92 | .seconds(rawValue) 93 | } 94 | } 95 | 96 | extension Tagged where Tag == SecondsTag, RawValue: BinaryFloatingPoint { 97 | /// Converts milliseconds into `Duration`. 98 | @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) 99 | public var duration: Duration { 100 | .seconds(Double(rawValue)) 101 | } 102 | } 103 | 104 | extension Date { 105 | /// Computes the number of seconds between the receiver and another given date. 106 | /// 107 | /// - Parameter date: The date with which to compare the receiver. 108 | /// - Returns: The number of seconds between the receiver and the other date. 109 | public func secondsSince(_ date: Date) -> Seconds { 110 | Seconds(rawValue: timeIntervalSince(date)) 111 | } 112 | 113 | /// Computes the number of milliseconds between the receiver and another given date. 114 | /// 115 | /// - Parameter date: The date with which to compare the receiver. 116 | /// - Returns: The number of milliseconds between the receiver and the other date. 117 | public func millisecondsSince(_ date: Date) -> Milliseconds { 118 | secondsSince(date).milliseconds 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tagged.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Tagged 3 | 4 | enum EmailTag {} 5 | typealias Email = Tagged 6 | 7 | struct User: Decodable { 8 | typealias Id = Tagged 9 | 10 | let id: Id 11 | let name: String 12 | let email: Email 13 | let subscriptionId: Subscription.Id? 14 | } 15 | 16 | struct Subscription: Decodable { 17 | typealias Id = Tagged 18 | 19 | let id: Id 20 | let ownerId: User.Id 21 | } 22 | 23 | let user = User( 24 | id: 1, 25 | name: "Blob", 26 | email: "blob@pointfree.co", 27 | subscriptionId: 1 28 | ) 29 | 30 | let subscription = Subscription(id: 1, ownerId: 1) 31 | 32 | let decoder = JSONDecoder() 33 | 34 | let users = try! decoder.decode( 35 | [User].self, 36 | from: Data( 37 | """ 38 | [ 39 | {"id": 1, "name": "Blob", "email": "blob@pointfree.co", "subscriptionId": 1}, 40 | {"id": 2, "name": "Brandon", "email": "brandon@pointfree.co", "subscriptionId": 1}, 41 | {"id": 3, "name": "Stephen", "email": "stephen@pointfree.co", "subscriptionId": null}, 42 | ] 43 | """.utf8 44 | ) 45 | ) 46 | 47 | let subscriptions = try! decoder.decode( 48 | [Subscription].self, 49 | from: Data( 50 | """ 51 | [ 52 | {"id": 1, "ownerId": 1}, 53 | ] 54 | """.utf8 55 | ) 56 | ) 57 | -------------------------------------------------------------------------------- /Tagged.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/TaggedMoneyTests/TaggedMoneyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TaggedMoney 3 | 4 | final class TaggedTimeTests: XCTestCase { 5 | func testDollarsToCents() { 6 | let dollars: Dollars = 12 7 | XCTAssertEqual(1200, dollars.cents) 8 | } 9 | 10 | func testCentsToDollars() { 11 | let cents: Cents = 1200 12 | XCTAssertEqual(12.0, cents.dollars) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/TaggedTests/TaggedTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Tagged 3 | 4 | enum Tag {} 5 | struct Unit: Error {} 6 | 7 | final class TaggedTests: XCTestCase { 8 | func testInit() { 9 | let int1 = Tagged(rawValue: 42) 10 | let int2 = Tagged(1729) 11 | XCTAssertNotEqual(int1, int2) 12 | } 13 | 14 | func testCustomStringConvertible() { 15 | XCTAssertEqual("1", Tagged(rawValue: 1).description) 16 | } 17 | 18 | func testComparable() { 19 | XCTAssertTrue(Tagged(rawValue: 1) < Tagged(rawValue: 2)) 20 | } 21 | 22 | func testDecodable() { 23 | XCTAssertEqual( 24 | [Tagged(rawValue: 1)], 25 | try JSONDecoder().decode([Tagged].self, from: Data("[1]".utf8)) 26 | ) 27 | } 28 | 29 | func testDecodableCustomDates() { 30 | let decoder = JSONDecoder() 31 | decoder.dateDecodingStrategy = .custom { decoder in 32 | let seconds = try decoder.singleValueContainer().decode(Int.self) 33 | return Date(timeIntervalSince1970: TimeInterval(seconds)) 34 | } 35 | 36 | XCTAssertEqual( 37 | [Date(timeIntervalSince1970: 1)], 38 | try decoder.decode([Date].self, from: Data("[1]".utf8)) 39 | ) 40 | 41 | XCTAssertEqual( 42 | [Tagged(rawValue: Date(timeIntervalSince1970: 1))], 43 | try decoder.decode([Tagged].self, from: Data("[1]".utf8)) 44 | ) 45 | } 46 | 47 | func testEncodable() { 48 | XCTAssertEqual( 49 | Data("[1]".utf8), 50 | try JSONEncoder().encode([Tagged(rawValue: 1)]) 51 | ) 52 | } 53 | 54 | func testEncodableCustomDates() { 55 | let encoder = JSONEncoder() 56 | encoder.dateEncodingStrategy = .custom { date, encoder in 57 | var container = encoder.singleValueContainer() 58 | let seconds = Int(date.timeIntervalSince1970) 59 | try container.encode(seconds) 60 | } 61 | 62 | XCTAssertEqual( 63 | Data("[1]".utf8), 64 | try encoder.encode([Date(timeIntervalSince1970: 1)]) 65 | ) 66 | 67 | XCTAssertEqual( 68 | Data("[1]".utf8), 69 | try encoder.encode([Tagged(rawValue: Date(timeIntervalSince1970: 1))]) 70 | ) 71 | } 72 | 73 | func testCodingKeyRepresentable() { 74 | if #available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) { 75 | enum Key {} 76 | let xs: [Tagged: String] = [Tagged("Hello"): "World"] 77 | XCTAssertEqual( 78 | String(decoding: try JSONEncoder().encode(xs), as: UTF8.self), 79 | #"{"Hello":"World"}"# 80 | ) 81 | } 82 | } 83 | 84 | func testEquatable() { 85 | XCTAssertEqual(Tagged(rawValue: 1), Tagged(rawValue: 1)) 86 | } 87 | 88 | func testError() { 89 | XCTAssertThrowsError(try { throw Tagged(rawValue: Unit()) }()) 90 | } 91 | 92 | #if canImport(Foundation) 93 | func testLocalizedError() { 94 | let taggedError: Error = Tagged(rawValue: Unit()) 95 | XCTAssertEqual(taggedError.localizedDescription, Unit().localizedDescription) 96 | 97 | struct DummyLocalizedError: LocalizedError { 98 | var errorDescription: String? { return "errorDescription" } 99 | var failureReason: String? { return "failureReason" } 100 | var helpAnchor: String? { return "helpAnchor" } 101 | var recoverySuggestion: String? { return "recoverySuggestion" } 102 | } 103 | let taggedLocalizedError: LocalizedError = Tagged(rawValue: DummyLocalizedError()) 104 | XCTAssertEqual(taggedLocalizedError.localizedDescription, DummyLocalizedError().localizedDescription) 105 | XCTAssertEqual(taggedLocalizedError.errorDescription, DummyLocalizedError().errorDescription) 106 | XCTAssertEqual(taggedLocalizedError.failureReason, DummyLocalizedError().failureReason) 107 | XCTAssertEqual(taggedLocalizedError.helpAnchor, DummyLocalizedError().helpAnchor) 108 | XCTAssertEqual(taggedLocalizedError.recoverySuggestion, DummyLocalizedError().recoverySuggestion) 109 | } 110 | #endif 111 | 112 | func testExpressibleByBooleanLiteral() { 113 | XCTAssertEqual(true, Tagged(rawValue: true)) 114 | } 115 | 116 | func testExpressibleByFloatLiteral() { 117 | XCTAssertEqual(1.0, Tagged(rawValue: 1.0)) 118 | } 119 | 120 | func testExpressibleByIntegerLiteral() { 121 | XCTAssertEqual(1, Tagged(rawValue: 1)) 122 | } 123 | 124 | func testExpressibleByStringLiteral() { 125 | XCTAssertEqual("Hello!", Tagged(rawValue: "Hello!")) 126 | } 127 | 128 | func testExpressibleByStringInterpolation() { 129 | XCTAssertEqual("Hello \(1 + 1)!", Tagged(rawValue: "Hello 2!")) 130 | } 131 | 132 | func testLosslessStringConvertible() { 133 | // NB: This explicit `.init` shouldn't be necessary, but there seems to be a bug in Swift 5. 134 | // Filed here: https://bugs.swift.org/browse/SR-9752 135 | XCTAssertEqual(Tagged(rawValue: true), Tagged.init("true")) 136 | } 137 | 138 | func testNumeric() { 139 | XCTAssertEqual( 140 | Tagged(rawValue: 2), 141 | Tagged(rawValue: 1) + Tagged(rawValue: 1) 142 | ) 143 | } 144 | 145 | func testHashable() { 146 | XCTAssertEqual(Tagged(rawValue: 1).hashValue, Tagged(rawValue: 1).hashValue) 147 | } 148 | 149 | func testSignedNumeric() { 150 | XCTAssertEqual(Tagged(rawValue: -1), -Tagged(rawValue: 1)) 151 | } 152 | 153 | func testMap() { 154 | let x: Tagged = 1 155 | XCTAssertEqual("1!", x.map { "\($0)!" }) 156 | } 157 | 158 | func testDynamicMemberLookup() { 159 | struct MyStruct { 160 | let val1: String = "val1" 161 | let val2: Int = 1 162 | } 163 | 164 | let x: Tagged = Tagged(rawValue: MyStruct()) 165 | XCTAssertEqual("val1", x.val1) 166 | XCTAssertEqual(1, x.val2) 167 | } 168 | 169 | func testOptionalRawTypeAndNilValueDecodesCorrectly() { 170 | struct Container: Decodable { 171 | typealias Identifier = Tagged 172 | let id: Identifier 173 | } 174 | 175 | XCTAssertNoThrow(try { 176 | let data = "[{\"id\":null}]".data(using: .utf8)! 177 | let containers = try JSONDecoder().decode([Container].self, from: data) 178 | XCTAssertEqual(containers.count, 1) 179 | XCTAssertEqual(containers.first?.id.rawValue, nil) 180 | }()) 181 | } 182 | 183 | func testOptionalRawTypeAndNilValueEncodesCorrectly() { 184 | struct Container: Encodable { 185 | typealias Identifier = Tagged 186 | let id: Identifier 187 | } 188 | 189 | XCTAssertNoThrow(try { 190 | let data = try JSONEncoder().encode([Container(id: Tagged(rawValue: nil))]) 191 | XCTAssertEqual(data, Data("[{\"id\":null}]".utf8)) 192 | }()) 193 | } 194 | 195 | func testIdentifiable() { 196 | 197 | struct User: Identifiable { 198 | let id: Tagged 199 | var location: String? 200 | } 201 | var user = User(id: 123) 202 | user.location = "Los Angeles" 203 | 204 | XCTAssertTrue(user.id == User(id: 123).id) 205 | } 206 | 207 | func testCollection() { 208 | let x: Tagged = .init(rawValue:[-1, -3, 57, 43]) 209 | XCTAssertFalse(x.isEmpty) 210 | XCTAssertTrue(x.contains(57)) 211 | XCTAssertFalse(x.contains(-57)) 212 | } 213 | 214 | func testCoerce() { 215 | let x: Tagged = 1 216 | 217 | enum Tag2 {} 218 | let x2: Tagged = x.coerced(to: Tag2.self) 219 | 220 | XCTAssertEqual(1, x2.rawValue) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Tests/TaggedTimeTests/TaggedTimeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TaggedTime 3 | 4 | final class TaggedTimeTests: XCTestCase { 5 | func testSecondsToMilliseconds() { 6 | let seconds: Seconds = 12 7 | XCTAssertEqual(12000, seconds.milliseconds) 8 | } 9 | 10 | func testMillisecondsToSeconds() { 11 | let milliseconds: Milliseconds = 12000 12 | XCTAssertEqual(12.0, milliseconds.seconds) 13 | } 14 | 15 | func testMillisecondsToTimeInterval() { 16 | let milliseconds: Milliseconds = 12000 17 | XCTAssertEqual(12.0, milliseconds.timeInterval) 18 | } 19 | 20 | func testMillisecondsToDate() { 21 | let milliseconds: Milliseconds = 12000 22 | XCTAssertEqual(Date(timeIntervalSince1970: 12), milliseconds.date) 23 | } 24 | 25 | func testSecondsSince() { 26 | let date1 = Date(timeIntervalSince1970: 12) 27 | let date2 = Date(timeIntervalSince1970: 15) 28 | XCTAssertEqual(3, date2.secondsSince(date1)) 29 | } 30 | 31 | func testMillisecondsSince() { 32 | let date1 = Date(timeIntervalSince1970: 12) 33 | let date2 = Date(timeIntervalSince1970: 15) 34 | XCTAssertEqual(3000, date2.millisecondsSince(date1)) 35 | } 36 | 37 | func testDate() { 38 | let seconds: Seconds = 12 39 | XCTAssertEqual(Date(timeIntervalSince1970: 12), seconds.date) 40 | 41 | let milliseconds: Milliseconds = 12000 42 | XCTAssertEqual(Date(timeIntervalSince1970: 12), milliseconds.date) 43 | } 44 | 45 | func testLossyMillisecondsToSeconds() { 46 | let milliseconds: Milliseconds = 12500 47 | let seconds = milliseconds.map(Double.init).seconds.map(Int.init) 48 | XCTAssertEqual(12, seconds) 49 | } 50 | 51 | func testDuration() { 52 | if #available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) { 53 | let intSeconds: Seconds = 12 54 | XCTAssertEqual(.seconds(12), intSeconds.duration) 55 | 56 | let doubleSeconds: Seconds = 1.2 57 | XCTAssertEqual(.seconds(1.2), doubleSeconds.duration) 58 | 59 | let intMilliseconds: Milliseconds = 12000 60 | XCTAssertEqual(.milliseconds(12000), intMilliseconds.duration) 61 | 62 | let doubleMilliseconds: Milliseconds = 1.2 63 | XCTAssertEqual(.milliseconds(1.2), doubleMilliseconds.duration) 64 | } 65 | } 66 | } 67 | --------------------------------------------------------------------------------