Newtype for Swift

The post that this one is a reply to (#15) is the last one I read before going on a research binge. Of course, this thread ran its course in the meantime with a lot of new posts. I still want to express my thoughts.


I looked through my old posts for previous thoughts on newtype/strong type-alias and/or reduced-state types.

I only looked through threads I started, not one proposed by others (whether or not I participated in those). The last post in that list inspired some of my thoughts below.


Generalizing

A general reduced-state type can accommodate newtypes by simply not excluding any states. Everyone before my last post in this thread seemed to want only in same-state-count types, so I'm going to restrict myself to that. Some of the later posts do mention wanting to disallow some states of the original type into the new type. I think we should consider reduced-state types later, although there is the problem to duplicating effort if we have separate means of creating same-count newtypes and reduced-state new types.

Reduced state types also will require us to resolve the co-variance vs. contra-variance for the trampoline members. Technically, same-count new types do have the same issue, but there's more at stake for reduced-state ones.

Declaring Conformance

Unfortunately, I closed the tab, but I read a thread on this forum that copied a response from another thread that stated that the Core Team wants to keep the conformance of a protocol to a type explicit. New ways of declaring/constructing types shouldn't secretly add conformances. I already expressed this wish too, but that other thread lets me emphasize it more. That means the newtype must have a ": MyProtocol" somewhere; even if we let a new type copy all of its source's members, it doesn't copy its conformances implicitly.

Note that the new requested feature for tuples to conform to Equatable, Comparable, and Hashable violates this, but it's more limited and may be rectified in later versions of Swift (if added at all) if general tuple conformance is added.

Acknowledging New-Types

This relates to the last thread in my list above.

  • Whether or not a type is a newtype is an implementation detail.
  • This means that a new type that has explicit cases will be declared as an enum, otherwise as a struct. There won't be a newtype sibling construction.
  • The declaration needs to specify the access level of the newtype-ness. For example, if the aliasing is declared fileprivate, then anything from a different file will only see the new type as an ordinary struct/enum, and only items within the source file can access the new type's init(rawValue:) initializer for forward conversion and use as/as!/as? for backward conversion.

(Despite the init(rawValue:) initializer, a new type doesn't conform to RawRepresentable by default. That conformance has to be explicitly added. If it's added then the new type's newtype-ness has to have the same publicity as the new type.)

Expression of newtype-ness

I'm thinking of a form like:

struct MyType: @newtype(internal) MySourceType, MyProtocol { /*...*/ }

where the access level may be omitted. If it is, the default is the most restrictive of the new type's publicity, the source type's publicity, and internal. Remember that access level determines who can see init(rawValue:) and use as/as!/as?.

The source type may be a struct, enum, tuple, or a single enum case. The last one has the same interaction model as a tuple of the same shape, but the storage is the source's enum type. (Any other structural value types we add, like sum-tuples or fixed-sized arrays, may also be a source type.) Within any members of the new type, access to the internal state as the source type is done through "super". If we specify RawRepresentable conformance without giving a definition, we will have rawValue automatically forward to super.

If the source type is a single enum case, the core initializer is fail-able, where it fails only if the input is of a different case.

For the items that can see the new type's as/as!/as?, their code can reinterpret an instance of the new type as the source type with "as". If the source type is itself a new type, it can convert down the "inheritance" chain. It can cross- or up-convert to a different new type that shares an ancestor. (The last two reinterpretations apply only when the original item's code has enough access to see the destination type's "inheritance" chain, at least to the common ancestor). If the up-cast phase of an reinterpretation involves an enum case, then "as?" or "as!" is required.

If the new type is a struct, then any items that cannot see the init(rawValue:) initializer can only reuse values you give a high-enough access to, or create values through functions you give a high-enough access to. (You can make this an empty list to keep your new type practically private.) If the new type is an enum and the source is an enum too, then you get the same cases, but you can override the names:

enum MyType: @newtype MyOldEnum {
    // Prototype syntax
    publish case old(hello: Int, Double) as new(Int, world: Double)
}

If a new case name conflicts with a different case of the source, then you need to rename that other old case too. If the new type is an enum and the source is anything else, then you need to specify the name of the sole case:

enum MyType: @newtype MyOldStruct {
    publish super as single
}

That sole case will have a payload with one unlabeled item of the source type. (If the source is an enum case, the payload is one item of the equivalent tuple.)

I'll leave specification of trampoline members for a later thread.

Addition: I forgot to write about new-typing a singular enum case. They work the same as if Void was their equivalent tuple.

1 Like