Newtype for Swift

"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.

Super excited to see this moving again! I'm curious how people thought newtype initialization might work. Hopefully this aspect isn't too implementation specific for the topic of this thread.

newtype ID = Int

For the case of Int and String, they happen provide identity initializers Int.init(_:Int) so I'd assume ID.init(_:Int) might work?

ID(Int(42))

But what would happen if you created a newtype of a type in another module that had no initializers available to you?

// Module 1
public struct Module1Foo { var value: String }
public let module1Foo = Module1Foo(value: "hello")

// Module 2
newtype Module2Foo = Module1Foo
Module2Foo(value: "hello") // but init was internal to the other module?
Module2Foo(module1Foo)) // could newtypes be constructed from original types?

To be more use case specific, if you only forwarded specific protocols, how would you create a new ID if it's only Hashable and Equatable?

newtype ID: Hashable, Equatable = String

It would be interesting if there were some ability to control how these newtypes are vended such that you could ensure an EmailAddress was validated but many of the capabilities of being a String.

1 Like

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

I mean, could you be a bit more specific in declaring our topic of discussion invalid? Why isn't this case worthy of a language feature?

I'm not saying the discussion is invalid, just stating my opinion on the merits of this specific feature. I think @lantua's comment strikes at the heart of the matter; there are so many different ways to eliminate the boilerplate here with multiple orthogonal configuration axes that it's not clear one feature could solve everyone's use case. If it doesn't achieve that, the temptation will be to keep extending it or add a new feature, etc.

1 Like

Right, and I was clear at the beginning that this topic was not an exploration of every possible axis of design regarding newtypes or something like them. Instead, it was to be focused on the most commonly needed features with the highest value that are furthest from the language's current capabilities (i.e. most bang for the buck). In the discussion, two needs were most common: complete duplication and duplication with control over protocol conformances. Complete duplication is also the most difficult to accomplish in the current language, which is why I think it's the most valuable.

No language feature can solve all aspects of newtype or similar design, but that doesn't mean we shouldn't discuss it to find the most parts that might be implementable.

1 Like

Unfortunately, we're creating a new type, so a lot of these axes need to be decided. We need to initialize it somehow. We need to add methods to these types. It needs to interact with synthesizer.

And having a mis-match makes it much less usable. Even the most defensive one (hides the type, show only selected conformance) raises the question of the visibility level of init and the original type. A little deviation from user's need could easily turn this into glorified struct declaration. The least defensive one would just turn this into one-way typealias (though, honestly, I do see an appeal to that as well).

1 Like

Not to discredit the problems needed to be solve, because, well, boilerplates are always problems. But if the problem is the code-forwarding boilerplate. We'd need protocol forwarding mechanism, rather than a brand new type. That way, we'd also truly leave the combinatorially exploded design space.

3 Likes

That would depend on whether protocol forwarding can be justified as a standalone feature and that's not a question this topic attempts to answer. newtype's forwarding could certainly be implemented in terms of that feature but I don't think it's existence would negate the need for newtype. I think this is especially true if newtype is focused around complete duplication and even covariance.

Sure, it could easily be a separate feature. Though from what little time I spent on this thread, it seems people do prefer something in between, pulling us back into that vast grey area.

That goes w/o saying for every feature, including this one.

My view is that protocol forward is the important feature in this space, and one which is definitely justified.

A dedicated newtype feature is much more narrow, less important, and can easily be constructed from protocol forwarding, especially if the latter includes a “forward all” option.

In other words, I strongly support protocol forwarding, and I see newtype as something of a novelty on top of it that might not be necessary.

4 Likes

Ditto. We already have the ability to forward properties defined by protocols using keypath-based dynamic member lookup. I'll admit that this is way easier said than done, but each time the newtype conversation pops up, I'd love to see efforts focused on filling in the gaps in that existing feature instead:

  1. Forwarding methods, initializers, etc. There's a related thread elsewhere where someone is pitching keypaths for unbound methods, which would fill part of this hole.

  2. Automatic protocol conformance through @dynamicMemberLookup. The following may be missing some subtle details, but

    • If protocol P contains only read-only properties, then a type T declaring subscript<U>(dynamicMember keyPath: KeyPath<P, U>) -> U is sufficient (?) for the containing type to satisfy P's requirements.
    • Likewise, if protocol P contains only read-only or read-write properties, then a type T declaring that subscript and subscript<U>(dynamicMember keyPath: WritableKeyPath<P, U>) -> U are sufficient to satisfy P's requirements.
    • But the compiler won't recognize the conformance T: P as valid today unless all of the requirements are explicitly written.
  3. Variadic generics are probably necessary to generically represent the subscript to support methods of arbitrary arity?

If we had forwarding for methods (non-mutating and mutating) and initializers and combined that with auto-conformances based on keypath @dynamicMemberLookup, I think we'd be most of the way to newtype without having to invent new syntax, and all the debate above about how to decide which protocols the new type conforms to or doesn't conform to goes away; you declare the conformances you want like you would any type, because it is just a regular type. There's no magic, no new rules to learn.

This approach would definitely be more verbose, but IMO that's fine for now: if we start with existing features, improve them, and compose them to get the feature we think we want, then we'll be in a better position to see deficiencies and potential future simplifications than if we dive head-first into new syntax. I'd wager this approach is a lot more likely to be successfully implemented as well, because it breaks the feature down into smaller, more manageable subproblems, many of which are already desired directions in the language.

4 Likes
Terms of Service

Privacy Policy

Cookie Policy