• Stars
    star
    1,364
  • Rank 34,464 (Top 0.7 %)
  • Language
    Swift
  • License
    MIT License
  • Created over 6 years ago
  • Updated 5 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

๐Ÿท A wrapper type for safer, expressive code.

๐Ÿท Tagged

CI

A wrapper type for safer, expressive code.

Table of Contents

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:

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:

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:

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.

import Tagged

struct User {
  let id: Id
  let email: String
  let address: String
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
}

struct Subscription {
  let id: Id

  typealias Id = Tagged<Subscription, Int>
}

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.

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.

let subscription = fetchSubscription(byId: user.id)

๐Ÿ›‘ Cannot convert value of type 'User.Id' (aka 'Tagged<User, Int>') to expected argument type 'Subscription.Id' (aka 'Tagged<Subscription, Int>')

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:

sendWelcomeEmail(toAddress address: String)

It contains logic that sends an email to an email address. Unfortunately, it takes any string as input.

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.

struct User {
  let id: Id
  let email: Email
  let address: String
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<User, String>
}

We can now update sendWelcomeEmail and have another compile time guarantee.

sendWelcomeEmail(toAddress address: Email)
sendWelcomeEmail(toAddress: user.address)

๐Ÿ›‘ Cannot convert value of type 'String' to expected argument type 'Email' (aka 'Tagged<EmailTag, String>')

Handling Tag Collisions

What if we want to tag two string values within the same type?

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<User, String>
  typealias Address = Tagged</* What goes here? */, String>
}

We shouldn't reuse Tagged<User, String> 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.

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  enum EmailTag {}
  typealias Email = Tagged<EmailTag, String>
  enum AddressTag {}
  typealias Address = Tagged<AddressTag, String>
}

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.

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  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:

user.id.rawValue // Int

You can also manually instantiate tagged types using init(rawValue:), though you can often avoid this using the Decodable and ExpressibleBy-Literal family of protocols.

Features

Tagged uses conditional conformance, 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.

Equatable

A tagged type is automatically equatable if its raw value is equatable. We took advantage of this in our example, above.

subscriptions.first(where: { $0.id == user.subscriptionId })

Hashable

We can use underlying hashability to create a set or lookup dictionary.

var userIds: Set<User.Id> = []
var users: [User.Id: User] = [:]

Comparable

We can sort directly on a comparable tagged type.

userIds.sorted(by: <)
users.values.sorted(by: { $0.email < $1.email })

Codable

Tagged types are as encodable and decodable as the types they wrap.

struct User: Decodable {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  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.

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!

struct Product {
  let amount: Cents

  typealias Cents = Tagged<Product, Int>
}
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<A> and Seconds<A>, that allow the compiler to sort out the differences for you. You can use them in your models:

struct BlogPost: Decodable {
  typealias Id = Tagged<BlogPost, Int>

  let id: Id
  let publishedAt: Seconds<Int>
  let title: String
}

Now you have documentation of the unit in the type automatically, and you can never accidentally compare seconds to milliseconds:

let futureTime: Milliseconds<Int> = 1528378451000

breakingBlogPost.publishedAt < futureTime
// ๐Ÿ›‘ Binary operator '<' cannot be applied to operands of type
// 'Tagged<SecondsTag, Double>' and 'Tagged<MillisecondsTag, Double>'

breakingBlogPost.publishedAt.milliseconds < futureTime
// โœ… true

Read more on our blog post: 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 libary gives you access to two generic types, Dollars<A> and Cents<A>, that give you compile-time guarantees in keeping the two units separate.

struct Prize {
  let amount: Dollars<Int> 
  let name: String
}

let moneyRaised: Cents<Int> = 50_000

theBigPrize.amount < moneyRaised
// ๐Ÿ›‘ Binary operator '<' cannot be applied to operands of type
// 'Tagged<DollarsTag, Int>' and 'Tagged<CentsTag, Int>'

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. 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 project, it's as simple as adding it to a dependencies clause in your Package.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, a video series exploring functional programming and Swift hosted by Brandon Williams and Stephen Celis.

Tagged was first explored in Episode #12:

video poster image

License

All modules are released under the MIT license. See LICENSE for details.

More Repositories

1

swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
Swift
12,150
star
2

swift-snapshot-testing

๐Ÿ“ธ Delightful Swift snapshot testing.
Swift
3,737
star
3

isowords

Open source game built in SwiftUI and the Composable Architecture.
Swift
2,667
star
4

swift-navigation

Bringing simple and powerful navigation tools to all Swift platforms, inspired by SwiftUI.
Swift
1,950
star
5

swift-dependencies

A dependency management library inspired by SwiftUI's "environment."
Swift
1,527
star
6

swift-overture

๐ŸŽผ A library for function composition.
Swift
1,139
star
7

pointfreeco

๐ŸŽฌ The source for www.pointfree.co, a video series on functional programming and the Swift programming language.
Swift
1,098
star
8

episode-code-samples

๐Ÿ’พ Point-Free episode code.
Swift
950
star
9

swift-case-paths

๐Ÿงฐ Case paths extends the key path hierarchy to enum cases.
Swift
905
star
10

swift-parsing

A library for turning nebulous data into well-structured data, with a focus on composition, performance, generality, and ergonomics.
Swift
847
star
11

swift-nonempty

๐ŸŽ A compile-time guarantee that a collection contains a value.
Swift
839
star
12

swift-custom-dump

A collection of tools for debugging, diffing, and testing your application's data structures.
Swift
795
star
13

swift-html

๐Ÿ—บ A Swift DSL for type-safe, extensible, and transformable HTML documents.
Swift
760
star
14

combine-schedulers

โฐ A few schedulers that make working with Combine more testable and more versatile.
Swift
701
star
15

swift-perception

Observable tools, backported.
Swift
536
star
16

swift-identified-collections

A library of data structures for working with collections of identifiable elements in an ergonomic, performant way.
Swift
529
star
17

swift-web

๐Ÿ•ธ A collection of Swift server-side frameworks for handling HTML, CSS, routing and middleware.
Swift
481
star
18

swift-prelude

๐ŸŽถ A collection of types and functions that enhance the Swift language.
Swift
469
star
19

swift-validated

๐Ÿ›‚ A result type that accumulates multiple errors.
Swift
392
star
20

swift-issue-reporting

Report issues in your application and library code as Xcode runtime warnings, breakpoints, assertions, and do so in a testable manner.
Swift
361
star
21

swift-url-routing

A bidirectional router with more type safety and less fuss.
Swift
347
star
22

swift-concurrency-extras

Useful, testable Swift concurrency.
Swift
315
star
23

swift-gen

๐ŸŽฑ Composable, transformable, controllable randomness.
Swift
266
star
24

swift-macro-testing

Magical testing tools for Swift macros.
Swift
263
star
25

swift-clocks

โฐ A few clocks that make working with Swift concurrency more testable and more versatile.
Swift
258
star
26

syncups

A rebuild of Appleโ€™s โ€œScrumdingerโ€ application using modern, best practices for SwiftUI development.
Swift
205
star
27

swift-enum-properties

๐Ÿค Struct and enum data access in harmony.
Swift
200
star
28

composable-core-location

A library that bridges the Composable Architecture and Core Location.
Swift
108
star
29

vapor-routing

A bidirectional Vapor router with more type safety and less fuss.
Swift
89
star
30

swift-html-vapor

๐Ÿ’ง Vapor plugin for type-safe, transformable HTML views.
Swift
84
star
31

swift-playground-templates

๐Ÿซ A collection of helpful Xcode playground templates.
Makefile
81
star
32

pointfreeco-server

Point-Free server code.
40
star
33

TrySyncUps

The starting project for our try! Swift 2024 Composable Architecture workshop.
Swift
38
star
34

composable-core-motion

A library that bridges the Composable Architecture and Core Motion.
Swift
29
star
35

swift-boundaries

๐Ÿฃ Functional core, imperative shell.
Swift
28
star
36

swift-quickcheck

๐Ÿ An implementation of QuickCheck in Swift.
Swift
25
star
37

swift-either

For those times you want A or B!
Swift
21
star
38

swift-algebras

Algebraic laws bundled into concrete data types.
20
star
39

swift-parser-printer

โ†”๏ธ Parsing and printing
Swift
15
star
40

swift-html-kitura

โ˜๏ธ Kitura plugin for type-safe, transformable HTML views.
Swift
14
star
41

swiftui-navigation

This package is now Swift Navigation:
Swift
13
star
42

homebrew-swift

Ruby
3
star
43

swift-bugs

3
star
44

Ccmark

Swift
2
star
45

.github

1
star