Skip to content

Instantly share code, notes, and snippets.

@anandabits
Last active March 30, 2023 14:58
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save anandabits/5b7f8e3836387e893e3a1197a4bf144d to your computer and use it in GitHub Desktop.
Save anandabits/5b7f8e3836387e893e3a1197a4bf144d to your computer and use it in GitHub Desktop.

Value Subtypes and Generalized Enums, a manifesto

The goal of this document is to provide a comprehensive view of what value subtyping might look like in Swift and demonstrate how generalized enums play a significant role in this future.

Note: All syntax used in this document that is not currently valid Swift syntax is only intended to serve the purpose of demonstrating ideas and to serve as a point of reference for future proposals. The intent is not to propose that this exact syntax be used.

Acknowledgement: some of the ideas in this document have been inspired by Niko Matsakis' blog post exploring similar ideas in the context of Rust: http://smallcultfollowing.com/babysteps/blog/2015/08/20/virtual-structs-part-3-bringing-enums-and-structs-together/

Definition

Before discussion specific features it is important to define what value subtyping is. It is useful to think about value subtyping in terms of set theory. A value type is a subtype of another value type when the values it can represent are a subset of the values that can be represented by the supertype.

Because values are passed by copy, value subtyping is in many respects a principled system of implicit conversions which is restricted to conversions that preserve the meaning of the value post-conversion. Sometimes this conversion in the type system will also require change in how a value is represented in memory and other times it will not.

It follows from this definition that a subtype may not be provided where an inout Supertype is required. After mutation an inout Supertype may hold a value that cannot be represented by the subtype.

It is important to observe that the semantics of value subtype relationships are much different than object oriented inheritance relationships. They have nothing to do with dynamic dispatch or implementation sharing. Another important difference is that providing a subclass instance where a superclass instance is required never requires a change in representation of the object reference itself.

Because value subtype relationships don't involve impelmentation sharing, the problems caused multiple inheritence simply don't exist. Value types may have more than one supertype. For example, Int8 can be a subtype of most integer types as well as Float, Double, and CGFloat without introducing any undesirable complications (other than perhaps making type inference of some expressions more challenging). This is an extremely powerful and useful property of value subtype relationships.

Finally, an important consequence of this definition is that it allows for value subtype relationships to be established syntactically in more than one way. This turns out to be very useful: there are at least two ways of defining value subtype relationships that turn out to be important.

Transitivity of Value Subtype Relationships

As with any subtype relationship, value subtype relationships are transitive. When we know that Int8: Int16 and Int16: Int32 we know also that Int8: Int32 without needing to state that explicitly.

More interesting and powerful uses of transitivity combined with subtype relationships defined by enums would allow the compiler to also infer subtype relationships such as Optional<Int8>: Optional<Int16>. It can infer this because the none case is identical for both Optional<Int8> and Optional<Int16> while the type of the some case of Optional<Int8> is a subtype of the type of the some case of Optional<Int16>. Therefore all possible values of type Optional<Int8> are also valid values of type Optional<Int16>. The compiler can infer an implicit conversion from Optional<Int8> to Optional<Int16> that preserves the meaning of the value.

Overload Resolution

Because Swift supports ad-hoc overloads there may be times where no overload exists for the concrete type but overloads exist for multiple supertypes. When this occurs the existing overload resolution suffices. It prefers the most direct supertype possible. When there are two more supertypes that do not have a subtype / supertype relationship with each other overload resolution fails (as can happen today with overloads of protocol existential types, for example).

Generic Supertype Constraints

Just as we allow superclasses to specify supertype constraints on generic types, we could allow value types with one or more subtypes to be used as a supertype constriant. This may be especially powerful in combination with other constraints:

protocol P {
  func foo() -> Self
}
func foo<I: Int, P>(i: I) -> I {
  return i.foo()
}

Axiomatic Value Subtype Relationships

Perhaps the simplest value subtype relationships that we desire are implicit value-preserving numeric promotion such as Int8: Int16. This is a very good example of what we might be called an axiomatic value subtype relationship: the compiler cannot prove the subtype relationship. It relies an assertion by humans that this relationship is in fact value and meaning preserving and on a user-defined conversion operation to be used when a value of the subtype is provided where a value of the supertype is required.

Axiomatic subtype relationships could be established using syntax such as:

extension Int8: Int16 {
  // some syntax TBD for specifying the conversion operation
  // possibly also some syntax for specifying the inverse of the conversion operation
  // which can be used by the compiler if the user attempts to downcast from the supertype to the subtype
  // (the inverse returns nil if the inverse is not a total function)
}

With this declaration in hand, the compiler is free to promomte values of type Int8 to values of type Int16 implicitly using the conversion operation specified by the user.

Determining whether a conversion preserves the meaning of a value is likely to be somewhat subjective. The good news is that value subtyping is far more principled than a more general "implicit conversion" feature would be. It establishes a semantic requirement that must be upheld. In many respects, this is similar to a semantic requirement of a protocol that cannot be verified by the compiler. The discussion regarding whether or not a value subtype relationship is valid is clearly defined. We cannot produce compiler errors for user-defined subtype relationships that are clearly an invalid abuse of the mechanism but hopefully the Swift community would generally expect the semantic requirements to be resepected and develop the cultural standard "when in doubt, leave it out".

The most basic criteria is obviously that it must be possible to define an inverse of the user-defined conversion operation which preserves the value* and therefore produces the identity function when it is composed with its inverse. While this criteria must be met in order for one value type to be a subtype of another it is not sufficient.

For example, a unit type Length<Int> might have a single Int field allowing conversion in both directions without any change in the magnitude. However, the case that Length<Int> should be a subtype of Int is somewhat dubious. It rests on how much weight you give to the meaning of the unit. It is likely a better choice to require users to say length.value when they require the magnitude alone. And it is quite obvious that Int is not a subtype of Lenght<Int>: not all integers are lengths.

Because axiomatic subtype relationships rely on human judgement allowing user-defined axiomatic subtype relationships is both a very powerful feature but also very ripe for abuse. It is probably best to proceeed cautiously, perhaps only allowing axiomatic value subtypes in the standard library (at least at first).

Identifying how we might allow user-defined axiomatic value subtype relationships while limiting and discouraging abuse of such a feature is likely a research project. It is also possible that we will have to choose between trusting programmers to use this power wisely or to decide that it is too likely to be abused and restrict it to the standard library indefinitely.

*The inverse that is required is only logical / theoretical. It may, but need not be actually implemented. It is allowed to restrict its domain to the set of values produced by the conversion operation itself. For example, the conversion from Int8 to Int16 has an inverse whose range is the set of values produced by the Int8 to Int16 conversion rather than all possible values of type Int16. In most cases, where the inverse is implemented it will need to have an Optional return type, allowing nil to be used to represent a supertype value that is outside the domain of the subtype.

Enums: Value Subtype Relationships By Definition

The fundamental essence of an enum is that it is a discriminated union. In a language with value subtyping it becomes possible to make this explicit. The enum is viewed as a union of disjoint types, one for each of its cases. The enum itself can represent all values of of all of the types of its cases. The values of the type of each case are by definition a subset of the values that can be represented by the enum type. This makes the enum a supertype of the type of each of its cases by definition.

In fact, an enum is logically an abstract supertype: you cannot directly construct a value of the supertype. You can only construct values of the types that make up its cases. This value is implicitly converted to the supertype when necessary (which in many cases will happen immediately).

Without any other language enhancements, the subtype for each case would be anonymous tuple-like types (tuple-like because they are distinct types from a tuple with the same "signature") whose initializer is not directly accessible, but only accessed via the case. As we will see, generalizing this to give programmers control over the type of each case is an extremely powerful idea.

An important observation that will be relevant as the discussion progresses is that the cases of an enum (as they exist in Swift today) allow users to do exactly two things syntactically:

  1. They allow values of the enum type to be constructed using syntax that is equivalent to a static factory method or property.
  2. They represent a pattern that can match all possible values of the case using syntax that mirrors that of value construction. This pattern is part of an exhaustive set of patterns (one for each case) that together match all possible values of the enum type itself.

It is possible to view this as syntactic sugar over more general underlying capabilities. This syntactic sugar is extremely useful in keeping common uses of enums concise but it is really orthogonal to the basic essence of an enum as a discriminated union.

Nominal Case Types

However, there is no reason that these must be anonymous. For example, one can imagine syntax like the following to assign a name to the type of an enum case:

enum Foo {
  case zero: struct Zero
  case one(Double) -> struct One
  case two(Int, String) -> struct Two
}

This syntax assigns the name Zero, One and Two to the type of values constructed using each case. When values are constructed using a case they have the type of the case, rather than the enum.

The syntax is derived by viewing one aspect of a case as a static factory method (or property) on the enum type which returns a value. The name of the type is preceded by the struct keyword that instructs the compiler to synthesize a struct that has a property for each associated value. The struct keyword is an important aspect of this syntax. It makes the compiler synthesis of the type explicit and distinguishes it from cases (discussed below) which have a type that is defined outside of the enum itself.

The above code using the nominal case type syntax can be desugared to something like this (ignoring the fact that 0 and 1 are not valid identifiers and this is a little bit hand-wavy with private):

enum Foo {
  struct Zero {
    private init() {}
  }
  struct One {
    let 0: Double
    private init(_ double: Double) {
      0 = double
    }
  }
  struct Two {
    let 0: Int
    let 1: String
    private init(_ int: Int, _ string: String) {
      0 = int
      1 = string
    }
  }
  
  var zero: Zero {
    return Zero()
  }
  func one(_ double: Double) -> One {
    return One(double)
  }
  func two(_ int: Int, string: String) -> Two {
    return Two(int, string)
  }

  // Imagine syntax for defining an exhaustive pattern set which has one pattern for each case.
  // The case for each of these patterns would enable matching syntax which mirrors the factory.
  // Strawman syntax for doing something like this is demonstrated at the end of this document.
}

Because the type of an enum is a supertype of the types of its cases, case values are implicitly converted to the enum type when necessary. Users can also attempt to cast to the type of a specific case, or use a cast pattern in an exhaustive switch statement to cover the case in question, optionally binding a name to the value with the subtype of the case.

Some example code using the previously defined enum:

func takesOne:(_ one: Foo.One) {}
func takesTwo(_ two: Foo.Two) {}

takesOne(Foo.one(42))
takesTwo(Foo.two(42, "one"))

// If we define synthesize case types as including the static factory method or property 
// associated with the case on the case type itself in addition to the enum type
// then the following shorthand would also be valid:
takesOne(.one(42))
takesTwo(.two(42, "one"))

func takesFoo(foo: Foo) {
  switch foo {
  case let one as .One: print("a double: \(one.0)")
  case let two as .Two: print("an int: \(two.0), a string: \(two.1)")
  }
}

Associated Value Labels

Swift allows users to specify labels for associated values. As demonstrated above, the associated value label is also used as a property name when the compiler synthesizes a struct for the case type.

SE-0155 clarifies the behavior of enum cases when used as value constructors. One step it leaves out is the ability to distinguish the argument label used when invoking the constructor from the name that we might want to use to refer to it as a property of value of the case type. Brent Royal-Gordon suggested that we allow enum cases to include external labels used by the value constructor that are distinct from the "internal" name used. If this were adopted, the property in the synthesized case type would use the "internal" name.

enum Foo {
  case itemAt(_ index: Int, in section: Int, hidden: Bool) -> struct Item
}

Desugars to something like:

enum Foo {
  struct Item {
    var index: Int
    var section: Int
    var isHidden: Bool
    private init(at index: Int, in section: Int, isHidden: Bool) {
      self.index = index
      self.section = section
      self.isHidden = isHidden
    }
  }
  
  func itemAt(_ index: Int, in section: Int, isHidden: Bool) -> Item {
    return ItemAt(index, in: section, isHidden: isHidden)
  }
}

Nominal Unions

When cases can be explicitly typed it is obvious that we might want to use existing types for the cases rather than having them define a new type. Such cases would be similar to cases with a single associated value, but instead of wrapping the value in a new case type, they would make the enum a direct supertype of the type of the associate value.

For example, we might conceive of something like this:

enum IntOrString {
  case int(Int) -> Int
  case string(String) -> String

  /* or maybe:
  case int: Int
  case string: String
  */
}

Becase IntOrString is a supertype of Int and String, users don't need to use the case to wrap a value when a value of type IntOrString is required:

func takesIntOrString(_ intOrString: IntOrString) {}

takesIntOrString(42)
takesIntOrString("string")

If we combine this with the cast patterns demonstrated in the prior section, we can see that the name of such a case is not really that useful. We could allow cases like this to be anonymous (note: it is the case itself that is anonymous here, not the type of the case):

enum IntOrString {
  // The case is simply assigned a type
  case -> Int
  case -> String
}

It is obvious that anonymous cases do not have a name they also do not have a static value constructor (i.e. factory property or method) and they do not have an associated pattern. Instead, values are constructed by implicit conversion and matched using a cast.

Generic Enums and Optional

Once we have the ability to specify a user-defined type for an enum case, of which the enum is a supertype, it becomes possible to define the subtype relationship T: Optional<T> by definition:

enum Optional<T> {
  case none // none has an anonymous case type
  case some(T) -> T
}

or possibly:

enum Optional<T> {
  case none -> struct None // none has the type Optional<T>.None
  case some(T) -> T
}

It is also tempting to define none have a top level None type, allowing for nil values that are compatible with any Optional type as follows:

struct None {}
let nil = None()

enum Optional<T> {
  case none -> None // none has the top-level type `None`
  case some(T) -> T
}

There is an interesting wrinkle to consider here when optionals are nested. The cases for a nested optional Optional<Optional<T>> have types None and Optional<T> and we must remember that None: Optional<T>. this means that a value of type None is compatible with both cases. When we provide a value of type None where a value of type Optional<Optional<T>> is required either case may be used. Overload resolution is performed to select the more specific case None, which does not require the additional conversion to Optional<T>.

Cases With Unbound Generic Arguments

Aside from compiler synthesis, case types, whether anonymous or nominal, are treated as any other type declared in the scope of the case. This means that in the second example above Optional<Int>.None is a different type than from Optional<String>.None, for example, just as if a nested struct called None had been manually declared in this location.

Structural Unions

As this document has demonstrated, as soon as we adopt the perspective that enums are discriminated unions of cases that have independent types (disjoint from each other) which may be specified by the programmer we have the power to define something that looks a lot like a structural union type.

The name of a union like this is often redundant with the types. Instead of requiring programmers to manually declare an enum when the name adds no useful information (and which is incompatible with a semantically identical type defined by others) it will be useful to provide a structural variant. The preceding IntOrString example can be omitted and simply written directly as Int | String.

Because the semantics of these structural union types are derived from nominal enums it is clear that the types must be disjoint and order must not matter (this is how enums work).

When a name does provide useful meaning and differentiation in the type system a nominal subtype could be declared using syntax such as the following:

enum IntOrString: Int | String {
  case -> Int
  case -> String
  // The cases are obviously redundant so they may be omitted if they don't have a name.
}

func takesAnonymousUnion(intOrString: Int | String) {}
func takesIntOrString(intOrString: IntOrString) {
  takesAnonymousUnion(intOrString)
}

This example is valid because the set of case types is equivalent to the set of types in the structural union.

Note: I am well aware of the resistance to supporting structural unions in Swift. I am hopeful that demonstrating how they fit very naturally into a lanague with value subtyping and user-declared case types this document demonstrates a reasonable way to incorporate them into Swift's type system. It would certainly be a shame to see the rest of these features described by this document implemented while structural unions are not. This would only result in users declaring a bunch of incompatible IntOrStringOrThisOrThat enums which would be very unfortunate.

It is important to notice very one important difference in the structural union types discussed here from the union types that have been proposed on Swift evolution is that any members that happen to be available on all of the case types in a syntactically uniform fashion are not made available on the union itself. No operations are available on the union at all aside from the ability to attempt a downcast. Because users cannot extend structural types, no members can be added by extension, ensuring that structural unions are not used where protocol-based poloymorphism is a better solution.

Enum Subtypes

When we conceive of enums as nominal unions it is clear that one enum could be a supertype of another enum. One way to accomplish this might be to add a cases keyword to specify that one enum is a subtype of another:

enum Sub {
  case one
  case two
}
enum Super {
  // Sub is defined elsewhere, it could even be in a different module if it is closed.
  cases Sub
  case three
  case four
  // Cannot declare cases named `one` or `two` because `Sub` already declares them.
}

This would make the following code valid:

func takesSuper(_ super: Super) {
  switch super {
  case .one: break // Sub.one could also be used
  case .two: break // Sub.two could also be used
  case .three: break
  case .four: break
  }
}

takesSuper(.one)
// or:
takesSuper(Sub.one)

Inline Enum Subtypes

It is also interesting to allow enum subtypes (aka subenums) to be declared inline:

enum Super {
  cases enum Sub {
    case one
    case two
  }
  case three
  case four
}

The full name of the Sub would be Super.Sub. The nesting could be arbitrarily deep. At each level in the hierarchy, the full set of cases is determined by flattening out all of the cases at the leaves of the hierarchy.

A good example of how this might be used is in definign an expression type:

enum Expr {
  cases enum IntExpr {
       case -> Int
       case plus(IntExpr, IntExpr)
       case minus(IntExpr, IntExpr)
       case times(IntExpr, IntExpr)
   }
  cases enum BoolExpr {
      case -> Bool
      case equal(IntExpr, IntExpr)
      indirect case and(BoolExpr, BoolExpr)
      indirect case or(BoolExpr, BoolExpr)
  }
}

This hierarchy allows some functions to work on all expressions while others are restricted to IntExpr or BoolExpr.

Aside: In the above example it would also be logically valid to axiomatically declare IntExpr: Int and BoolExpr: Bool. However, this would probably be a bad idea to do in practice because conversion requires the evaluation of an arbitrarily complex expression.

Inline Generic Enum Subtypes

Enums which contain subenums that introduce new generic arguments introduce a new form of type erasure. Consider the following example:

enum Foo {
  cases Bar<T> {
    case first(String)
    case second(T)
  }
  case third
}

This is an inverse of generic arguments introduced by the supertype that are unbound in the subtype. Bar<T> is a subtype of Foo for all types T. When a value first: Bar<Int> or second(42) is converted to Foo the type system loses track of what type T actually is. This has important consequences for pattern matching:

let foo: Foo = .second(42)

switch foo {
  // ok: String is a concrete type that is known regardless of what `T` is.
  case .first(let string): break

  // ok: value has an existential type with the same constraints as `T` (`Any` in this example).
  case .second(let value): break

  case .third: break
}

Note: if the set of types meeting the constraints on T is statically knowable (i.e. closed) it should be possible to use nested cast patterns to perform an exhaustive switch and recover the concrete type of the associated value of second.

Conditional Cases

The names IntExpr and BoolExpr should stand out as forming a pattern that we would like to abstract. This can be accomplished by introducing a generic parameter and syntax to constrain or bind it for some cases:

enum Expr<T> {
  case -> T
  cases where T == Int {
       indirect case plus(Expr<Int>, Expr<Int>)
       indirect case minus(Expr<Int>, Expr<Int>)
       indirect case times(Expr<Int>, Expr<Int>)
   }
  cases where T == Bool {
      case equal(Expr<Int>, Expr<Int>)
      indirect case and(Expr<Bool>, Expr<Bool>)
      indirect case or(Expr<Bool>, Expr<Bool>)
  }
}

In this example the supertype of the types of the cases plus, minus, and times is Expr<Int>. The supertype of equal, and and or is Expr<Bool>. The case -> T declares that Int: Expr<Int> and Bool: Expr<Bool>, for example. This example also demonstrates good use anonymous cases for T. The case types for plus, minus, times, equal, and, and or remain anonymous but could be given names if there was a reason to do so (and could even become full-fledged structs if that was desirable).

The where clause tells the type system that the enclosed cases are only valid for concrete Expr types whose argument T meets the specified constraints. The example above uses the most straightforward constraint possible, but more sophisticated constraints may also be interestion to allow.

The semantics of allowing constraints other than same type constrataints is identical to that of cases with unbound generic arguments, but with some constraints that limit the number of bindings applicable to the case (rather than allowing any possible type to be substituted). The types of the enclosed cases would be subtypes of the enum for all possible bindings of the generic argument which satisfy the constraints.

Readers who are familiar with the functional programming concept of Generalized Algebraic Data Types should notice that conditional cases offer (I believe) at least as much power as GADTs (perhaps more - I haven't considered this carefully) while using a syntax that is more at home in Swift and is arguably more intuitive.

Inline Case Types

Another interesting possibility is the idea that user-defined case types could be declared as inline structs:

enum Foo {
  case struct Bar {}
}

These cases would behave the same as the anonymous cases discussed in the section on nominal union types: instances of the enum of the case in question would be created by implicit conversion:

func takesFoo(_ foo: Foo) {}

takesFoo(Foo.Bar())

// or maybe simply:
takesFoo(.Bar())

They would be matched by casting:

switch foo {
  case let bar as .Bar: break
}

Nominal Cases with Inline Types

It would also be possible to provide sugar allowing concise declaration of the associated static factory method or property, thereby giving the case a name:

enum Foo {
  case bar struct Bar {}
}

// desugars to:
enum Foo {
  struct Bar {}
  var bar: Bar {
    return Bar()
  }
}

or with an "associated value":

enum Foo {
  case bar(Int) struct Bar { 
    var value: Int
    private init(_ value: Int) {
      self.value = value
    }
  }
}

// desugars by mapping "associated values" to initializer arguments:
enum Foo {
  struct Bar { 
    var value: Int
    private init(_ value: Int) {
      self.value = value
    }
  }
  static func bar(_ value: Int) -> Bar {
    return Bar(value)
  }
}

With syntax for declaring user-defined patterns it would also be possible to provide a pattern that has the same name as the "factory method". This would provide users to define cases which behave very much like the basic cases that Swift already provides, but with the ability to have more sophisticated behavior where necessary.

Obviously there is no reason to write out the entire struct when all it does is store the arguments of a memberwise initializer to corresponding properties. This facility becomes useful when we wish to define a struct that has more sophisticated behavior. For example, we might wish to make one or more of the properties private or may have an internal representation that is different than we wish to expose as arguments to the case initializer bindings we wish to allow in the case pattern.

Case Type Implementation Sharing

Shared Stored Properties

Sometimes we may wish to have a set of stored properties which is common to the type of all cases for an enum. When we wish to do this, there is no reason to require these properties to be repeated in each case. We could allow them to be declared in the scope of an enum whose cases all have inline types. The inline types would receive a memberwise initializer that includes all properties declared in either the enum or the type itself. For example:

enum Foo {
  var index: Int

  case struct Zero {
    var value: String
  }
  case struct One {
    var value: Int
  }
  
  func doSomething() {
    if index < 1 {
      // handle the less than one case
    } else {
      // handle the greater than one case
    }
  }
}

let zero = Foo.Zero(index: 0, value: "")
let one = Foo.One(index: 1, value: 42)

One interesting thing to notice in the previous example is the implementation of doSomething. Because all cases are known to have a stored property index of type Int the compiler can arrange so that this property is stored at the same offset for all cases, thus allowing a method of the enum to reference the value directly rather than requiring it to be stored associated with each case and extracted via pattern matching.

Subenum Stored Properties

Combining subenums and shared stored properties allow a property to be shared by some, but not all, cases. Sometimes it may be desirable to use a subenum to factor out shared properties despite otherwise not requiring a nominal subenum. In these situations it could remain anonymous:

enum Foo {
  case none
  cases { // an anonymous subenum with shared properties
    let int: Int
    let string: String

    case one
    case two(Double)
    case three
  }
}

In this example, one, two and three all have int: Int and string: String properties which are made available by the memberwise case initializer.

Methods and Computed Properties

It probably makes sense for any method or computed property defined on an enum type to be available on nominal or inline case types of its cases as well. The relationship of enum case types to the enum type is a very close subtype / supertype relationship that justifies an exception to the rule that supertype members are not imported into the scope of a subtype.

enum Foo {
  case struct One {}
  case struct Two {}
  func doSomething() {}
}

Foo.One().doSomething()
Foo.Two().doSomething()

func takesFoo(_ foo: Foo) {
  foo.doSomething()
}

takesFoo(.One())

These methods should not be considered "inherited" in an object-oriented sense. Case types would not be allowed to override them. They are required to cast or match a case to work with any associated data (other than shared stored properties which are always available). They are a single implementation that is sufficient for values of any case of that enum. When the compiler is able to statically determine which case a value is from it should be free to specialize the method in a manner similar to generic specialization, removing conditionals and unnecessary code paths from the optimized method.

Note: these methods would not be imported into the namespace of a case with a type that is defined outside of the case itself. For example:

enum Foo {
  case one(Int) -> Int
  case two(String) -> String
  
  func doSomething() {}
}

let foo: Foo = .one(42)

// ok: the type is `Foo`
foo.doSomething()

42.doSomething() // ERROR
// not allowed: method `doSomething` of `Int` supertype `Foo` is not available on values of type `Int`.  Bind the value to a name with type `Foo` or pass `Int` to the unbound method `Foo.doSomething` if you wish to invoke it.

// ok: uses the uncurried, unbound instance method, with the argument `42` being implicitly converted to `.one(42)`
Foo.doSomething(42)

User-Defined Case Patterns

Enum case patterns can be viewed as consisting of a name and zero or more values exracted from the value that is matched. An exhaustive pattern set consists of mutually exclusive patterns which together match all possible values represented by a type.

The following example shows how we might allow users to define custom pattern sets which are guaranteed to be both exhaustive and mutually exclusive (note this example uses a hypothetical spaceship operator for comparison):

struct Foo {
  var isActive: Bool
  var value: Int // this is only relevent when `isActive` is true

  patterns {
    pattern inactive
    pattern negative(Int)
    pattern zero
    pattern positive(Int)
    
    switch (isActive, value <=> 0) {
    case (false, _):             return inactive
    case (true, .orderedBefore): return negative(value)
    case (true, .same):          return zero
    case (true, .orderedAfter):  return after(value)
    }
  }
}

func takesFoo(_ foo: Foo) {
  // exhaustive switch on a struct with a user-defined pattern set
  switch foo {
  case .inactive:            break
  case .negative(let value): // do something with the value
  case .zero:                // do something with the value
  case .positive(let value): // do something with the value
  }
}

This is not a particularly useful example but hopefully it serves to demonstrate the idea and the strawman syntax that is used. The patterns keyword introduces a set of patterns that must be mutually exclusive. Each pattern has a name which consists of the base name as well optional labels for any associated values. Mutual exclusivity and exahaustiveness is guaranteed by using a switch statement that is only allowed to reference the state of the value. Each case of the switch returns a single "pattern value" which also contains any associated values exposed by the pattern. (Note: each case of the switch must return a single "pattern value", but multiple cases could return the same "pattern value").

The syntax used to define patterns here is not particularly elegant. I hope we would be able to identify more elegant and concise syntax before a proposal would be introduced. That said, it is sufficient for demonstrating how a user-defined pattern set which is guaranteed to be both mutually exclusive and exhaustive might be introduced.

Patterns from one set could be mixed with patterns from another in a switch statement, but doing so require users to include a default clause because exhaustiveness could not be guaranteed. It may also result in unreachable cases. Both of these are already true of non-exhaustive switch statements so this would be acceptable. We could also allow the definition of standalone patterns that are only usable in non-exhaustive switch statements, although the value of this seems questionable.

Note: the example provided shows how we could define a pattern set for a struct, but could also be used to construct pattern sets for other kinds of types as well. For example, there is no we couldn't define a second exhaustive pattern set for an enum (in addition to the default set the enum provides) if there was a meaningful reason to do so.

@BuggusMageevers
Copy link

This is all such great stuff! I would’ve over to see this become a reality in Swift. Found this manifesto from the Enum Inheritance pitch. I have often had some situations in which I would have liked value types to support these very features. Here’s hoping this manifesto is looked upon with favor as Swift developes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment