Newtype for Swift

One thing I would like to bring up is my experience at my current client. For context, I will explain that we have an Identifier type much like the Point Free Tagged type. Mostly this is fine, because we use the type the identifier is for as the phantom type:

struct User {
    typealias ID = Identifier<User, String>
    let id: ID
}

Occasionally though we have to create a new type whose sole purpose is to be the phantom type because it's not really an identifier for some thing:

enum _RefreshToken {}
typealias RefreshToken = Identifier<_RefreshToken, String>

Most developers in the team I would say are skeptical of the benefits specialised types for these can bring – the compiler not allowing one to set a refresh token to someone's user ID for insance. Mostly they will say "well of course we wouldn't do that!" However, we've definitely had subtle bugs where a product ID is used in place of a SKU ID for instance.

The point I want to raise for this thread is that it is this second instance where people really push back against have a hard time adopting types. Although in this project we've made it possible to have a two line definition of a new type, the second example isn't elegant enough to convince people of the benefits and the former is still a difficult push.

So while we can define a type with not a huge amount of code it's not going to convince people who want a one liner. It would be easier to convince people to adopt the creation of new types, as newtype itself would lend credence to the idea. With these two points therefore, I don't necessarily believe that newtype has to provide a massive amount extra over a one line struct for teams and the community to benefit from its addition.

As an aside, I'm really liking Tim's suggestion for this which would allow the type to conform to only the protocols that the user specifies, in quite a nice compact way.

newtype RefreshToken: Hashable, Equatable, Codable = String

Initially, I felt as if I would be on the side of automatic protocol conformance for newtype but we're already seeing problems with our Identifier type which has some conditional conformances which is desirable for a large number of use cases (Codable is one such case), but is entirely inappropriate for some uses. Which means we then have to fall back to creating new structs for those types.

8 Likes

I agree with this approach and share a very similar experience. If it becomes cumbersome to explicitly list a long, common set of protocols, we could just leverage protocol composition.

That doesn't really solve the issue of tracking changes to the original type through versions of the language. If we have to explicitly list every conformance we lose any automatic compatibility. Personally I think tracking all conformances is the more important feature, but a syntax that would allow you to do would be great. Really I think it just comes down to what newtype New = Original means. I'd prefer it to mean you get everything, since that gives you the most value. Explicitly stating protocols would then just map those. There would be no "nothing" case. Personally, I see no value there, and I haven't found any of the motivating cases offered for that version of the feature particularly compelling.

5 Likes

So you would have newtype New = Original automatically conform New to all protocols that Original conforms to. And (assuming that Original conformed to protocols A, B, and C) then newtype New: A, B = Original would make New conform to A and B but not C?

2 Likes

Yes, I think that gives us the most value for newtype.

I could get behind that.

So can I. It gives you both the freedom to simply wrap and cheap constraint.

How would this work with retroactive conformances?

1 Like

When this struct needs to conform to codable is where something like a newtype would be really helpful.

struct Account: Codable {
  newtype ID = String
  let credits: Int
  let id: ID
}

is much nicer than having to write a forwarding wrapper so that you can correctly encode and decode without having to write a lot of boilerplate for the Account object.

+1

My expectation would be that New inherits (only) conformances which are visible at the declaration of the newtype. I.e.

// Module A
protocol A {}
struct Original: A {}

// Module B
protocol B1 {}
protocol B2 {}
extension Original: B1 {}
newtype New = Original

// Module C
protocol C {}
extension Original: B2, C {}

let n = New()
print(n is A) // true
print(n is B1) // true
print(n is B2) // false
print(n is C) // false

If the newtype explicitly lists conformances, (newtype New: A, B = Original) only those conformances are adopted.

"Visible at the declaration" gets difficult to reason about when one or more of these modules is a resilient library...

4 Likes

Sure, but you don't have to write boilerplates if it's for a basic conforming type like String, as below.

struct Account: Codable {
  struct ID: RawRepresentable, Codable { var rawValue: String }
  let credits: Int
  let id: ID
}
let a = Account(credits: 420, id: .init(rawValue: "foo"))
let d = try! JSONEncoder().encode(a)
print(String(data: d, encoding: .utf8)!)  // {"credits":420,"id":"foo"}

This type of simple replacement of Int or String would be the most useful and popular pattern is my opinion, but I understand that that's not the primary use case we are discussing here.

I see the benefit of newtype and I'll definitely use it if it's available, but I just don't know if the use cases are convincing.

The snag here is that RawRepresentable is a public protocol, so you can't make your ID type publicly visible without allowing library users to create IDs from arbitrary strings, which is something that's (for me at least) often undesirable.

For examples like these I really want the new type I create to be just a black box for library clients, often with no or only carefully chosen conformances and members visible whatsoever.

4 Likes

More specifically, I was thinking “...visible at the newtype declaration at the time of compilation of Module B,” so that if Module A were a resilient library that added a conformance to a protocol A2 to Original, New would not receive this conformance. newtype New = Original would basically be a shorthand for tracking down every statically knowable conformance A, B, ..., Z of Original and writing newtype New: A, B, ..., Z = Original (which is what could then be printed in the interface file, to avoid confusion). If Module B is a resilient library and the author wants to take advantage of a new conformance of Original, they will have to recompile and release a new binary for their library.

I admit I wish the general discussion on this forum was centered more on "how do I achieve what I want to do with the existing language features" rather than "here's a new language feature that solves my immediate problem today, what does everything think of adding it to the language?"

14 Likes

There is no way to get what we want with current language features, and previous discussions have charted the distances between the two. Anything that attempts to cross that distance seems to require new language features of some kind, unless you have solutions that everyone else here missed?

You can wrap your type in a new struct type, and implement the protocol conformances you want to forward manually. This seems like the most straightforward solution to me.

1 Like

They are still boilerplates. Then again, given the absurd amount of configurability:

  • Whether init/original type is public,
  • Whether or not to forward all/some protocol conformances/methods,
  • Whether or not to be a subtype of the original type (ties in to some protocol conformances, like Equatable),

I don't see how we can reach a satisfying conclusion, given that the goal is to have compact design. We can pretty much debate this ad nauseam.

2 Likes

That is the problem we're trying to fix with this discussion. You can manually do whatever you want. If we want any part of it to be automatic, then we need a new language feature. Do you have some specific disagreement with that goal?

1 Like

If we want any part of it to be automatic, then we need a new language feature.

You're right, but I'm questioning the premise here. I believe that in this case, deriving the boilerplate automatically is not worth the cost in more complexity in the language and implementation.

1 Like