Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/pointfreeco/swift-tagged
🏷 A wrapper type for safer, expressive code.
https://github.com/pointfreeco/swift-tagged
conditional-conformance swift tagged-types type-safety
Last synced: 21 days ago
JSON representation
🏷 A wrapper type for safer, expressive code.
- Host: GitHub
- URL: https://github.com/pointfreeco/swift-tagged
- Owner: pointfreeco
- License: mit
- Created: 2018-04-16T16:01:27.000Z (over 6 years ago)
- Default Branch: main
- Last Pushed: 2023-06-29T14:25:04.000Z (over 1 year ago)
- Last Synced: 2024-03-14T22:33:35.416Z (8 months ago)
- Topics: conditional-conformance, swift, tagged-types, type-safety
- Language: Swift
- Homepage: https://www.pointfree.co/episodes/ep12-tagged
- Size: 145 KB
- Stars: 1,288
- Watchers: 28
- Forks: 61
- Open Issues: 4
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Code of conduct: .github/CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# 🏷 Tagged
[![CI](https://github.com/pointfreeco/swift-tagged/workflows/CI/badge.svg)](https://actions-badge.atrox.dev/pointfreeco/swift-tagged/goto)
[![](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)
[![](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)A wrapper type for safer, expressive code.
## Table of Contents
- [Motivation](#motivation)
- [The problem](#the-problem)
- [The solution](#the-solution)
- [Handling tag collisions](#handling-tag-collisions)
- [Accessing raw values](#accessing-raw-values)
- [Features](#features)
- [Nanolibraries](#nanolibraries)
- [FAQ](#faq)
- [Installation](#installation)
- [Interested in learning more?](#interested-in-learning-more)
- [License](#license)## Motivation
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.
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.
Tagged can help solve serious runtime bugs at compile time by wrapping basic types in more specific contexts with ease.
## The problem
Swift has an incredibly powerful type system, yet it's still common to model most data like this:
``` swift
struct User {
let id: Int
let email: String
let address: String
let subscriptionId: Int?
}struct Subscription {
let id: Int
}
```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:
``` swift
func fetchSubscription(byId id: Int) -> Subscription? {
return subscriptions.first(where: { $0.id == id })
}
```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:
``` swift
let subscription = fetchSubscription(byId: user.id)
```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!
## The solution
We can use Tagged to succinctly differentiate types.
``` swift
import Taggedstruct User {
let id: Id
let email: String
let address: String
let subscriptionId: Subscription.Id?typealias Id = Tagged
}struct Subscription {
let id: Idtypealias Id = Tagged
}
```Tagged depends on a generic "tag" parameter to make each type unique. Here we've used the container type to uniquely tag each id.
We can now update `fetchSubscription` to take a `Subscription.Id` where it previously took any `Int`.
``` swift
func fetchSubscription(byId id: Subscription.Id) -> Subscription? {
return subscriptions.first(where: { $0.id == id })
}
```And there's no chance we'll accidentally pass a user id where we expect a subscription id.
``` swift
let subscription = fetchSubscription(byId: user.id)
```> 🛑 Cannot convert value of type 'User.Id' (aka 'Tagged') to expected argument type 'Subscription.Id' (aka 'Tagged')
We've prevented a couple serious bugs at compile time!
There's another bug lurking in these types. We've written a function with the following signature:
``` swift
sendWelcomeEmail(toAddress address: String)
```It contains logic that sends an email to an email address. Unfortunately, it takes _any_ string as input.
``` swift
sendWelcomeEmail(toAddress: user.address)
```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.
Tagged again can save the day.
``` swift
struct User {
let id: Id
let email: Email
let address: String
let subscriptionId: Subscription.Id?typealias Id = Tagged
typealias Email = Tagged
}
```We can now update `sendWelcomeEmail` and have another compile time guarantee.
``` swift
sendWelcomeEmail(toAddress address: Email)
`````` swift
sendWelcomeEmail(toAddress: user.address)
```> 🛑 Cannot convert value of type 'String' to expected argument type 'Email' (aka 'Tagged')
### Handling Tag Collisions
What if we want to tag two string values within the same type?
``` swift
struct User {
let id: Id
let email: Email
let address: Address
let subscriptionId: Subscription.Id?typealias Id = Tagged
typealias Email = Tagged
typealias Address = Tagged* What goes here? */, String>
}
```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.
``` swift
struct User {
let id: Id
let email: Email
let address: Address
let subscriptionId: Subscription.Id?typealias Id = Tagged
enum EmailTag {}
typealias Email = Tagged
enum AddressTag {}
typealias Address = Tagged
}
```We've now distinguished `User.Email` and `User.Address` at the cost of an extra line per type, but things are documented very explicitly.
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.
``` swift
struct User {
let id: Id
let email: Email
let address: Address
let subscriptionId: Subscription.Id?typealias Id = Tagged
typealias Email = Tagged<(User, email: ()), String>
typealias Address = Tagged<(User, address: ()), String>
}
```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.
### Accessing Raw Values
Tagged uses the same interface as `RawRepresentable` to expose its raw values, _via_ a `rawValue` property:
``` swift
user.id.rawValue // Int
```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.
## Features
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).
### Equatable
A tagged type is automatically equatable if its raw value is equatable. We took advantage of this in [our example](#the-problem), above.
``` swift
subscriptions.first(where: { $0.id == user.subscriptionId })
```### Hashable
We can use underlying hashability to create a set or lookup dictionary.
``` swift
var userIds: Set = []
var users: [User.Id: User] = [:]
```### Comparable
We can sort directly on a comparable tagged type.
``` swift
userIds.sorted(by: <)
users.values.sorted(by: { $0.email < $1.email })
```### Codable
Tagged types are as encodable and decodable as the types they wrap.
``` swift
struct User: Decodable {
let id: Id
let email: Email
let address: Address
let subscriptionId: Subscription.Id?typealias Id = Tagged
typealias Email = Tagged<(User, email: ()), String>
typealias Address = Tagged<(User, address: ()), String>
}JSONDecoder().decode(User.self, from: Data("""
{
"id": 1,
"email": "[email protected]",
"address": "1 Blob Ln",
"subscriptionId": null
}
""".utf8))
```### ExpressiblyBy-Literal
Tagged types inherit literal expressibility. This is helpful for working with constants, like instantiating test data.
``` swift
User(
id: 1,
email: "[email protected]",
address: "1 Blob Ln",
subscriptionId: 1
)// vs.
User(
id: User.Id(rawValue: 1),
email: User.Email(rawValue: "[email protected]"),
address: User.Address(rawValue: "1 Blob Ln"),
subscriptionId: Subscription.Id(rawValue: 1)
)
```### Numeric
Numeric tagged types get mathematical operations for free!
``` swift
struct Product {
let amount: Centstypealias Cents = Tagged
}
```
``` swift
let totalCents = products.reduce(0) { $0 + $1.amount }
```## Nanolibraries
The `Tagged` library also comes with a few nanolibraries for handling common types in a type safe way.
### `TaggedTime`
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.
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:
```swift
struct BlogPost: Decodable {
typealias Id = Taggedlet id: Id
let publishedAt: Seconds
let title: String
}
```Now you have documentation of the unit in the type automatically, and you can never accidentally compare seconds to milliseconds:
```swift
let futureTime: Milliseconds = 1528378451000breakingBlogPost.publishedAt < futureTime
// 🛑 Binary operator '<' cannot be applied to operands of type
// 'Tagged' and 'Tagged'breakingBlogPost.publishedAt.milliseconds < futureTime
// ✅ true
```Read more on our blog post: [Tagged Seconds and Milliseconds](https://www.pointfree.co/blog/posts/6-tagged-seconds-and-milliseconds).
### `TaggedMoney`
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.
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.
```swift
struct Prize {
let amount: Dollars
let name: String
}let moneyRaised: Cents = 50_000
theBigPrize.amount < moneyRaised
// 🛑 Binary operator '<' cannot be applied to operands of type
// 'Tagged' and 'Tagged'theBigPrize.amount.cents < moneyRaised
// ✅ true
```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.
## FAQ
- **Why not use a type alias?**
Type aliases are just that: aliases. A type alias can be used interchangeably with the original type and offers no additional safety or guarantees.
- **Why not use `RawRepresentable`, or some other protocol?**
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!
## Installation
You can add Tagged to an Xcode project by adding it as a package dependency.
> https://github.com/pointfreeco/swift-tagged
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`:
``` swift
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0")
]
```## Interested in learning more?
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).
Tagged was first explored in [Episode #12](https://www.pointfree.co/episodes/ep12-tagged):
## License
All modules are released under the MIT license. See [LICENSE](LICENSE) for details.