Newtype for Swift

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.

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

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

3 Likes

I worry the proposed combination of keypaths to unbound method references, dynamic member lookup, and variadic generics is going to end up producing less readable code than writing out the protocol forwarding by hand would.

Indeed, if you find yourself writing out too much forwarding boilerplate, it might be a sign that the protocols you're working with have too many requirements and that maybe the protocols can be split up, or perhaps there is some other refactoring along those lines that will eliminate the boilerplate, rather than relying on language support.

1 Like

That's not unreasonable. Thinking about it does give me C++ template metaprogramming feels.

Do you think that this is a problem better solved through better tooling instead of language support? Using SourceKit, someone could write a thing that takes a type and generates a new type that wraps an instance of it, along with requirements for the protocols it conforms to, possibly filtered by a list that the user provides.

2 Likes

What’s the difference between a newtype with complete duplication and a typealias?

newtypes are new types (for the purposes of type-checking), unlike typealiases:

newtype Meters = Int allows you to create functions taking and returning Meters without accepting Int, while keeping Meters zero-cost.

2 Likes

Is that really worth it, though?

Let’s take your example of newtype-ing Int. You couldn’t perform any extra validation or whatever, because of ExpressibleByIntegerLiteral and the various mathematics methods and operators. Even if the base type didn’t have that conformance, I could create an equally validation-breaking conformance (perhaps by mistake, or imported from a library) and suddenly see all kinds of methods and initialisers you didn’t expect me to use.

So complete duplication ends up being pretty useless in practice, IMO. If you’re making a new type, you essentially always want to limit the functionality it exposes - even if that’s a long list.

I don't really have a horse in this race, but I can share my thoughts.

I mostly agree. I believe validation is possible, and that validation and limiting functionality are both common use cases.

Opaque type aliases are the equivalent feature in Scala 3. The opaque type Logarithm = Double example shows both validation and limiting functionality:

  • Validation: make "underlying type to newtype" conversion be failable. Double -> Logarithm? is the only public conversion API.
  • Limiting functionality: in Scala 3, I think newtypes inherit no methods or operators. Logarithm's public APIs are all defined via extensions.
1 Like

I think we should do a better job answering to Slava and Karl's comments. They're quite right suggesting this proposal adds complexity, and whether the benefits are worth it we can only weigh by looking at concrete and conplete examples of potentiall uses of the proposed newtype.

Take this example for a metaexample:

Quantities with units in particular are a great example of types which ought not to automatically conform to all protocols of their wrapped type. For length, it might be better to have typealias Meters = Double, but the problem here is that we probably want Meters(10) * Meters(10) to equal to SquareMeters(100) and Meters(10) / Meters(2) to the unitless value 5.

This gets us back to the unsaid motivation I had for my requirement 3 of no autoconformances by default. What newtype is probably most used for in Haskell is to give type-checked semantics to function arguments. If a function needs to do more than forward the values of another such function and no such conformances exist on the newtype, then it can unwrap the value (let meters: Int = length.value) and do the arithmetics on the standard type instead. This of course is less of a problem with Swift because at least we can convey this information in argument labels all the way between function implementation and the call site (still contrived example):

func areaSqMeters(widthMeters: Int, heightMeters: Int) -> Int {
    widthMeters * heightMeters
}
let areaM2 = areaSqMeters(widthMeters: widthMeters, heightMeters: heightMeters)

but of course this becomes not only a lot more pleasant with types but also the information is moved to the type checker:

func rectangleArea(width: Meters, height: Meters) -> SquareMeters {
    // Whether Meters conforms to a protocol mostly matters here,
    // and I'd say if the wrapped type can be unwrapped by this code,
    // then it's a better default to not autoinherit conformances.
}
let area = rectangleArea(width: width, height: height)

Still, this is a toy example; we'd probably get our units from a units library and newtype would insted be used for one-off cases like newtype AgeInCatYears = Int where an extra dependency isn't wanted or the unit alone isn't precise enough.

So, I welcome discussion on concrete usage examples and benefits of newtype. Without that, I don't think it's a feature important enough to include.

3 Likes

My concrete example using a concept similar to newtype is for adding type safe identifiers for model entities.
For convenience I use the Tagged framework by pointfree. https://github.com/pointfreeco/swift-tagged

This framework forwards Hashable, Equatable, Codable and adds RawRepresentable as well as literal conversion.

This is the only real life use case that I have ever wanted myself - and the Tagged library fulfills this need just fine.

My only issue (which is a bit off-topic for this thread) is that there is currently no way to use these types as keys in Dictionaries and have them encode to Dictionaries - they will be encoded as arrays of key value pairs. If newtype was a non-backwards compatible feature of the language perhaps it would be possible to remedy this behavior for newtypes of Strings and Ints in the same go?

I’m not very familiar with Scala, but this sounds like a wrapper struct in Swift.

That’s another, big problem - let’s say I have a Meters newtype, but now I need to pass that in to a maths library for some calculations. Now we need to add a way to undo the newtype.

That presents a bunch more issues - is it an instance member? If so, what is it called and how do we avoid conflicts? What access control does it have? Or is it a new expression, like #unwrap(meters) or something?

All of this makes me think it’s not worth it.

Selective, protocol-based forwarding seems like the way to go IMO. It defines a clear set of functionality you want to “inherit” and provides clear answers to all of the other problems, in a way that is consistent with the rest of the language. The only downside is that protocols cannot have non-public requirements right now, but that’s a long overdue feature anyway.

1 Like

Unlike single-field wrapper structs (which representationally are an extra layer of indirection), Scala's opaque type aliases and Haskell's newtype are representationally identical to the wrapped type - it's a new type just for type-checking purposes.

The former may be (guaranteed to be?) optimized away by Swift, but the latter is a representational guarantee.


Some reading that helped my understanding:

  • Difference between data and newtype in Haskell
    • This is the difference between a wrapper struct (data) and an opaque type alias (newtype).
    • There are some Haskell-specific details about strict vs lazy evaluation, which aren't relevant in the context of Swift.
  • Roles in GHC
    • This blog post explains "nominal equality" (newtype is different from underlying type) versus "representational equality" (newtype is same as underlying type). It also explains how "protocol conformance correctness" is impacted by these definitions of type equality.
3 Likes

This is why I've been saying the conversions need to be allowed two ways: both wrapping (init(_:) or whatever it'd be called) and unwrapping (.value) should be public.

1 Like