├── Tagged.playground
├── contents.xcplayground
└── Contents.swift
├── .editorconfig
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Tests
├── TaggedMoneyTests
│ └── TaggedMoneyTests.swift
├── TaggedTimeTests
│ └── TaggedTimeTests.swift
└── TaggedTests
│ └── TaggedTests.swift
├── Sources
├── Tagged
│ ├── UUID.swift
│ └── Tagged.swift
├── TaggedMoney
│ └── TaggedMoney.swift
└── TaggedTime
│ └── TaggedTime.swift
├── Package.swift
├── .github
├── workflows
│ └── ci.yml
└── CODE_OF_CONDUCT.md
├── Package@swift-5.9.swift
├── LICENSE
├── .gitignore
├── CODE_OF_CONDUCT.md
└── README.md
/Tagged.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Sources/Tagged/UUID.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Foundation)
2 | import Foundation
3 |
4 | extension Tagged where RawValue == UUID {
5 | /// Generates a tagged UUID.
6 | ///
7 | /// Equivalent to `Tagged(UUID(())`.
8 | public init() {
9 | self.init(UUID())
10 | }
11 |
12 | /// Creates a tagged UUID from a string representation.
13 | ///
14 | /// - Parameter string: The string representation of a UUID, such as
15 | /// `DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF`.
16 | public init?(uuidString string: String) {
17 | guard let uuid = UUID(uuidString: string)
18 | else { return nil }
19 | self.init(uuid)
20 | }
21 | }
22 | #endif
23 |
--------------------------------------------------------------------------------
/Package.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 | swiftLanguageModes: [.v6]
24 | )
25 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Package@swift-5.9.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | #if swift(>=6.0)
113 | extension Tagged: BitwiseCopyable where RawValue: BitwiseCopyable {}
114 | #endif
115 |
116 | extension Tagged: ExpressibleByBooleanLiteral where RawValue: ExpressibleByBooleanLiteral {
117 | public init(booleanLiteral value: RawValue.BooleanLiteralType) {
118 | self.init(rawValue: RawValue(booleanLiteral: value))
119 | }
120 | }
121 |
122 | extension Tagged: ExpressibleByExtendedGraphemeClusterLiteral
123 | where RawValue: ExpressibleByExtendedGraphemeClusterLiteral {
124 | public init(extendedGraphemeClusterLiteral: RawValue.ExtendedGraphemeClusterLiteralType) {
125 | self.init(rawValue: RawValue(extendedGraphemeClusterLiteral: extendedGraphemeClusterLiteral))
126 | }
127 | }
128 |
129 | extension Tagged: ExpressibleByFloatLiteral where RawValue: ExpressibleByFloatLiteral {
130 | public init(floatLiteral: RawValue.FloatLiteralType) {
131 | self.init(rawValue: RawValue(floatLiteral: floatLiteral))
132 | }
133 | }
134 |
135 | extension Tagged: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral {
136 | public init(integerLiteral: RawValue.IntegerLiteralType) {
137 | self.init(rawValue: RawValue(integerLiteral: integerLiteral))
138 | }
139 | }
140 |
141 | extension Tagged: ExpressibleByStringLiteral where RawValue: ExpressibleByStringLiteral {
142 | public init(stringLiteral: RawValue.StringLiteralType) {
143 | self.init(rawValue: RawValue(stringLiteral: stringLiteral))
144 | }
145 | }
146 |
147 | extension Tagged: ExpressibleByStringInterpolation
148 | where RawValue: ExpressibleByStringInterpolation {
149 | public init(stringInterpolation: RawValue.StringInterpolation) {
150 | self.init(rawValue: RawValue(stringInterpolation: stringInterpolation))
151 | }
152 | }
153 |
154 | extension Tagged: ExpressibleByUnicodeScalarLiteral
155 | where RawValue: ExpressibleByUnicodeScalarLiteral {
156 | public init(unicodeScalarLiteral: RawValue.UnicodeScalarLiteralType) {
157 | self.init(rawValue: RawValue(unicodeScalarLiteral: unicodeScalarLiteral))
158 | }
159 | }
160 |
161 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
162 | extension Tagged: Identifiable where RawValue: Identifiable {
163 | public var id: RawValue.ID {
164 | rawValue.id
165 | }
166 | }
167 |
168 | extension Tagged: LosslessStringConvertible where RawValue: LosslessStringConvertible {
169 | public init?(_ description: String) {
170 | guard let rawValue = RawValue(description) else { return nil }
171 | self.init(rawValue: rawValue)
172 | }
173 | }
174 |
175 | extension Tagged: AdditiveArithmetic where RawValue: AdditiveArithmetic {
176 | public static var zero: Self {
177 | Self(rawValue: .zero)
178 | }
179 |
180 | public static func + (lhs: Self, rhs: Self) -> Self {
181 | Self(rawValue: lhs.rawValue + rhs.rawValue)
182 | }
183 |
184 | public static func += (lhs: inout Self, rhs: Self) {
185 | lhs.rawValue += rhs.rawValue
186 | }
187 |
188 | public static func - (lhs: Self, rhs: Self) -> Self {
189 | Self(rawValue: lhs.rawValue - rhs.rawValue)
190 | }
191 |
192 | public static func -= (lhs: inout Self, rhs: Self) {
193 | lhs.rawValue -= rhs.rawValue
194 | }
195 | }
196 |
197 | extension Tagged: Numeric where RawValue: Numeric {
198 | public init?(exactly source: some BinaryInteger) {
199 | guard let rawValue = RawValue(exactly: source) else { return nil }
200 | self.init(rawValue: rawValue)
201 | }
202 |
203 | public var magnitude: RawValue.Magnitude {
204 | rawValue.magnitude
205 | }
206 |
207 | public static func * (lhs: Self, rhs: Self) -> Self {
208 | Self(rawValue: lhs.rawValue * rhs.rawValue)
209 | }
210 |
211 | public static func *= (lhs: inout Self, rhs: Self) {
212 | lhs.rawValue *= rhs.rawValue
213 | }
214 | }
215 |
216 | extension Tagged: Hashable where RawValue: Hashable {}
217 |
218 | extension Tagged: SignedNumeric where RawValue: SignedNumeric {}
219 |
220 | extension Tagged: Sequence where RawValue: Sequence {
221 | public consuming func makeIterator() -> RawValue.Iterator {
222 | rawValue.makeIterator()
223 | }
224 | }
225 |
226 | extension Tagged: Strideable where RawValue: Strideable {
227 | public func distance(to other: Self) -> RawValue.Stride {
228 | rawValue.distance(to: other.rawValue)
229 | }
230 |
231 | public func advanced(by n: RawValue.Stride) -> Self {
232 | Tagged(rawValue: rawValue.advanced(by: n))
233 | }
234 | }
235 |
236 | extension Tagged: ExpressibleByArrayLiteral where RawValue: ExpressibleByArrayLiteral {
237 | public init(arrayLiteral elements: RawValue.ArrayLiteralElement...) {
238 | let f = unsafeBitCast(
239 | RawValue.init(arrayLiteral:) as (RawValue.ArrayLiteralElement...) -> RawValue,
240 | to: (([RawValue.ArrayLiteralElement]) -> RawValue).self
241 | )
242 |
243 | self.init(rawValue: f(elements))
244 | }
245 | }
246 |
247 | extension Tagged: ExpressibleByDictionaryLiteral where RawValue: ExpressibleByDictionaryLiteral {
248 | public init(dictionaryLiteral elements: (RawValue.Key, RawValue.Value)...) {
249 | let f = unsafeBitCast(
250 | RawValue.init(dictionaryLiteral:) as ((RawValue.Key, RawValue.Value)...) -> RawValue,
251 | to: (([(Key, Value)]) -> RawValue).self
252 | )
253 |
254 | self.init(rawValue: f(elements))
255 | }
256 | }
257 |
258 | #if canImport(Foundation)
259 | import Foundation
260 |
261 | extension Tagged: LocalizedError where RawValue: Error {
262 | public var errorDescription: String? {
263 | rawValue.localizedDescription
264 | }
265 |
266 | public var failureReason: String? {
267 | (rawValue as? LocalizedError)?.failureReason
268 | }
269 |
270 | public var helpAnchor: String? {
271 | (rawValue as? LocalizedError)?.helpAnchor
272 | }
273 |
274 | public var recoverySuggestion: String? {
275 | (rawValue as? LocalizedError)?.recoverySuggestion
276 | }
277 | }
278 | #endif
279 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🏷 Tagged
2 |
3 | [](https://actions-badge.atrox.dev/pointfreeco/swift-tagged/goto)
4 | [](https://swiftpackageindex.com/pointfreeco/swift-tagged)
5 | [](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* What goes here? */, String>
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 |
394 |
395 |
396 | ## License
397 |
398 | All modules are released under the MIT license. See [LICENSE](LICENSE) for details.
399 |
--------------------------------------------------------------------------------