Git Product home page Git Product logo

swift-tagged's Introduction

๐Ÿท 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.

swift-tagged's People

Contributors

buscarini avatar dragna avatar ericlewis avatar justinmilo avatar kaunamohammed avatar maisamilena avatar mattyoung avatar mbrandonw avatar mdarnall avatar pteasima avatar rermolov avatar stephencelis avatar theotherdave avatar tonyarnold avatar volkdmitri avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

swift-tagged's Issues

Deploy to CocoaPods

Has the latest version (0.4) deployed to CocoaPods?

brad$ pod install --repo-update
Updating local specs repositories
Analyzing dependencies
[!] CocoaPods could not find compatible versions for pod "Tagged":
  In Podfile:
    Tagged (~> 0.4)

None of your spec sources contain a spec satisfying the dependency: `Tagged (~> 0.4)`.

You have either:
 * mistyped the name or version.
 * not added the source repo that hosts the Podspec to your Podfile.

Note: as of CocoaPods 1.0, `pod repo update` does not happen on `pod install` by default.

Support `SignedInteger` where `RawValue: SignedInteger`

In a current project I am using Tagged as the Interval of a type Note conforming to Strideable. This works great as far as it goes. However, I have a ClosedRange<Note> on which I would like to be able to compute count. This requires Tagged conform to SignedInteger where RawValue: SignedInteger.

Tagged has a number of conditional conformances and does conditionally support Numeric and SignedNumeric, there currently is not support for SignedInteger as far as I can tell. I'm curious if this was intentionally left out, or if it would be reasonable to add?

Issue building with Carthage when targeting older iOS SDK

I have an app that targets iOS 11 and when trying the new iOS/Xcode betas I had an issue with Tagged where when it is compiled it targets the latest version and gives messages like this: "Module file's minimum deployment target is ios12.0"

I worked around this by adding an iOS Deployment Target in addition to the macOS Deployment Target already present, this seems to have fixed the issue

Has anyone been able to get this library working with SQLite.swift?

I get the following error

error: conditional conformance of type 'Tagged<Tag, RawValue>' to protocol 'Value' does not imply conformance to inherited protocol 'Expressible'

both when I try


extension Tagged: Value where Tag == MyTag, RawValue == Int {
  public static var declaredDatatype = Int.declaredDatatype
  public typealias ValueType = Tagged<MyTag, Int>
  public typealias DataType = Int
  public var datatypeValue: Int {
    return self.rawValue
  }

  public static func fromDatatypeValue(_ datatypeValue: Int) -> Tagged<MyTag, Int> {
    return Tagged<MyTag, Int>(datatypeValue)
  }
}

and when I try

extension Tagged: Value where Tag == MyEntity, RawValue == Int {
  public static var declaredDatatype = Int.declaredDatatype
  public typealias ValueType = MyEntity.Id
  public typealias DataType = Int
  public var datatypeValue: Int {
    return self.rawValue
  }

  public static func fromDatatypeValue(_ datatypeValue: Int) -> MyEntity.Id {
    return MyEntity.Id(datatypeValue)
  }
}

So I try to add Expressible to the list of conformances, but I can't figure out how to implement Expressibles requirements. If I don't try to add a function to conform to Expressible, I get a crash.

Fatal error: 'try!' expression unexpectedly raised an error: 
Unexpected null value for column `"my_entity_id"`: 
file .build/checkouts/SQLite.swift/Sources/SQLite/Typed/Query.swift, line 1113

Decodable issue when using Tagged type as key in Dictionary

Hey @stephencelis and @mbrandonw!

I'm using Tagged in my latest project and it works great but I found the following issue and I'm not sure where the error is.

I'm trying to decode JSON into a Dictionary where the key is a tagged type like this:

let json = """
{
    "data": {
        "key": "value"
    }
}
""".data(using: .utf8)!

struct SomeStruct: Decodable {
    typealias Key = Tagged<SomeStruct, String>
    let data: [Key: String]
}

Strangely I receive this error message on decoding:

typeMismatch(Swift.Array, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "data", intValue: nil)], debugDescription: "Expected to decode Array but found a dictionary instead.", underlyingError: nil))

When I change the type of the dictionary to [String : String] it works as expected.

Any ideas?

Thank you in advance and keep up the great work with Pointfree.co!

Custom date decoding issue

Hi, I updated to the latest version and I run into an issue with custom date decoding. I think commit 4e607f7 broke that, and now a custom date decoding strategy can't be used for Tagged types, it is simply ignored. This seems like a swift bug to me, but for now I think the best option is to revert that change.

Unused dependency in Package.swift?

Hi there ๐Ÿ‘‹

I was noticing that since the merge of #10, my projects using Tagged have more transitive dependencies.
image

This seems due to this dependency specifier in Package.swift:

.package(url: "https://github.com/yonaskolb/XcodeGen.git", from: "2.2.0"),

From what I can see in the rest of Package.swift, none of the library or test targets depend on any targets from XcodeGen. I'm curious if/why this is needed, or if it's something that can be removed.

Thanks!

Unexpected behavior

Hey all, I was just trying something out and ran into an odd issue. I am not entirely sure what the compiler is thinking.

The below code has a problem when it comes to decoding, with JSONDecoder throwing Expected to decode Array<Any> but found a dictionary instead. on files, what is interesting is if we change the type to: [String: File] it works perfectly fine. I didn't dig in to it much, but it seems like the ExpressibleByArrayLiteral conformance is being interpreted incorrectly.

Problem code:

struct Gist {
  let id: Self.ID
  let files: [File.Name: File]

  typealias ID = Tagged<Self, String>
}

extension Gist: Identifiable {}
extension Gist: Codable {}

struct File {
  let filename: Name
  let content: String

  typealias Name = Tagged<Self, String>
}

extension File: Codable {}

CodingKeyRepresentable Conformance

I just discovered that when a dictionary whose keys are a Tagged type, they get encoded as an array of alternating {key}, {value}, ... pairs instead of as an actual object. This is a well known problem with Dictionaries of non "simple" key types (see here, and apparently a (relatively) new solution to this is to have the key type implement the CodingKeyRepresentable protocol.

I was able to add this conformance to Tagged as an extension, but it made me wonder if it makes sense for this conformance to be added to Tagged directly.

Are consumers able to override default Codable conditional conformances?

I have a user id that, for legacy reasons, can come back as either a String or an Int and when adding Tagged to handle this case I was hoping to extend Tagged for this type specifically and add a custom Codable implementation to clean this up. The only problem is that the runtime always calls the default conditional conformance implementations rather than my custom ones.

This is a more general Swift question perhaps, but is there a way to override this implementation and make sure the runtime calls my version? Here's the code:

public struct User: Codable {

    public enum UserIdTag {}
    public typealias Id = Tagged<UserIdTag, String>

    let id: Id
}

extension Tagged where Tag == User.UserIdTag, RawValue == String {

    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        do {
            try self.init(rawValue: String(container.decode(Int.self)))
        } catch {
            try self.init(rawValue: container.decode(String.self))
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(Int(rawValue))
    }
}

New Release?

๐Ÿ‘‹ hi, thank you for this library and your work on point free. Curious if there will be a new release with the changes in master since 0.4.0.

Conform to EntityIdentifierConvertible

I tried implementing conformity to EntityIdentifierConvertible in AppIntents, but I couldn't make it work. I'd like to be able to do the following:

import Tagged
struct Route: Identifiable, Codable, Sendable {
	typealias ID = Tagged< Route, String>
	let id: ID
}

import AppIntents
struct RouteEntity: AppEntity, Identifiable {
	var id: Route.ID
	var name: String
	
	// ...
}

Now, as a work-around I define id in the entity as Route.ID.RawValue but that defeats the purpose a bit. What do you think?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.