[Pitch] Property-Based Type Narrowing and Union Types

Title:

Property-Based Type Narrowing and Union Types

Author:

John McGlone

Introduction:

This proposal introduces two interrelated enhancements to the Swift type system:

  1. Value-based union types (A | B)
  2. Flow-sensitive type narrowing based on property checks

These features enable safer and more expressive control flow when working with values of multiple possible types, similar to what languages like TypeScript and Kotlin support.


Motivation:

Currently, Swift relies heavily on manual type casting (as?, as!) and enums with associated values to handle values that may conform to multiple types. This creates boilerplate and weakens readability in many situations, especially when working with protocols or loosely structured models.

In contrast, TypeScript allows developers to write more expressive code by narrowing union types based on:

  • Property presence ('meow' in animal)
  • Literal discriminants (type == "cat")
  • Type guards (instanceof, typeof)

We propose a similar system for Swift, allowing smarter narrowing in if, switch, and similar control flow blocks.


Proposed Solution

1. Introduce Union Types

Enable declaring a parameter that can be one of multiple value types:

func handle(_ input: Cat | Dog) { ... }

2. Enable Flow-Sensitive Type Narrowing by Property Presence

Allow Swift to infer the type based on property existence, so this code becomes valid:

func speak(_ animal: Cat | Dog) {
    if animal.meow != nil {
        // animal is inferred as Cat
        animal.meow()
    } else {
        // animal is inferred as Dog
        animal.bark()
    }
}

3. Narrowing with Literal Discriminants

When using a shared type field or enum-like value:

struct Cat {
    let type = "cat"
    let meow: () -> Void
}
struct Dog {
    let type = "dog"
    let bark: () -> Void
}

func speak(_ animal: Cat | Dog) {
    if animal.type == "cat" {
        animal.meow() // inferred as Cat
    } else {
        animal.bark() // inferred as Dog
    }
}

Detailed Design

This would require:

  • Introducing structural union types
  • Enhancing the compiler's type inference engine to track property accesses across branches
  • Possibly introducing syntactic sugar like 'property' in object or has(object.property)

Source Compatibility

This would be an additive change to the language and would not break existing Swift code.


Alternatives Considered

  • Continue relying on as?, enums, and protocols
  • Use enums with associated values (heavier but already supported)
  • Type erasure patterns (boilerplate-heavy)

Conclusion

By adopting property-based type narrowing and union types, Swift becomes more expressive, reducing boilerplate and improving safety when dealing with heterogeneous types — especially in modern app development and data-driven UIs.

1 Like

While I’d like to eventually see union types I’m not comfortable with relying that much on type inference, it’s already quite slow.

The second point relies on inference, something like is Type or as? Type to make it explicit instead would be the best of both worlds I think.

For your third point, you’re reimplementing existing language features, like protocols/classes so I don’t think this is necessary.

2 Likes

I really like your suggestion of using explicit type checks like this:

if animal is Cat {
    animal.meow()
} else if animal is Dog {
    animal.bark()
}

The feature I'm describing though aligns more closely with TypeScript's discriminated unions. Rather than using type checks, it relies on property-based narrowing. A clearer and more practical example than the silly animal illustration would look like this:

struct ResponseData {
    let status = "success"
    let data: Data
}

struct ResponseError {
    let status = "error"
    let error: Error
}

// Hypothetical Union Type Declaration
typealias NetworkResponse = ResponseData | ResponseError

func handleResponse(_ response: NetworkResponse) {
    // Property-based narrowing using the literal discriminant `status`
    if response.status == "success" {
        print("Received data of length: \(response.data.count)")
    } else {
        // response.error is *not* nil because response.status is *not* "success"
        print("An error occurred: \(response.error.localizedDescription)")
    }
}

Would this now be valid?

if foo != nil {
  foo.doSomething()
}

(I know that if let foo is as easy to type -- but if we're doing inference like that, it seems that we should apply it everywhere.)

:thinking:I think "Union Types" is listed in Commonly Rejected Changes.

  • Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."

That is source breaking change:

5 Likes

Kotlin works around this problem by only allowing this when value is an immutable local or stored variable, and not when it’s mutable or has a getter. In Swift terms, only lets can do narrowing here, not vars (and obviously not return values from functions).

1 Like

As with the previous pitch on union types, I‘m still against a change to allow them.
The Swift type system is really strict, and this is a core principle of Swift, and allowing union types would soften this

Also, if animal.meow != nil { is wrong from a logical point of view. nil does not indicate a non-existent object/value in Swift, but indicates that a specific optional type has no value assigned to it.
Your syntax would mean that both Cat and Dog have a (meow() → Void)? and a (bark() → Void)? variable which is not set in one of the 2 types.

9 Likes

As with previous pitches of this kind, I support a way to spell out non-nominal sum types, aka. unnamed enums.

Just as a tuple is a non-nominal struct (product type), we should support non-nominal enums.

Sum type Product type
nominal enum Animal struct AgeAndName
non-nominal (Cat | Dog) (Int, String)

This would fill a gap in the existing type system, without creating a true union type or introduce fundamentally new concepts. We could reuse almost all existing primitives and syntax from enums and tuples:

  • Use case let to pattern match, like enum
  • Use switch-statements like enum
  • Use case .0(let cat) for unnamed cases, similar to .0 for unnamed tuple elements
  • Allow labels (dog: Dog | cat: Cat) similar to how tuples can be labelled (name: String, age: Int)

The trivial empty sum () (aka a never-type) would be disallowed, to avoid ambiguation with the empty product () (aka. the unit type Void)

7 Likes

The Kotlin approach is great. This would be sufficient, in my opinion.

I'd be wary of introducing a new kind of non-nominal type because it would naturally require a bunch of special case logic as tuples already have, and we still hit limitations of tuples because of that.

So instead of using that as a starting point, I think I'd rather have the tools to build up a named variadic generic enum type. Completely strawman syntax below and probably broken in many ways, but if we assume we could express labeled generic arguments where the labels themselves could be arbitrary (i.e., not fixed labels that separate sequences of unlabeled arguments, hence the location of label relative to each here), then I could imagine something like this:

enum Animal<each label: T> {
  repeat case each label(each T)
}

// then this
Animal<dog: Dog, cat: Cat>

// would expand to something like
enum Animal<dog: Dog, cat: Cat> {
  case dog(Dog)
  case cat(Cat)
}

And maybe if label is omitted, then the index is used instead.

That would be strictly more powerful/flexible, because you could add additional fields to the payloads if you wanted:

enum Located<each kind: ASTNode> {
  repeat case each kind(each ASTNode, location: SourceLocation)
}

let x: Located<decl: Decl, expr: Expr, stmt: Stmt>
switch x {
case .decl(let decl, let location): // ...
case .expr(let expr, let location): // ...
case .stmt(let stmt, let location): // ...
}

Then, that foundation could be used to build up what you're talking about, if deemed useful: the standard library could propose to add an enum Either<each choice: Choice> type, and the compiler could add (a: A | b: B | ...) as syntax sugar for Either<a: A, b: B, ...> instead of making it an entirely new kind of non-nominal.

(I'm already seeing that each kind: T would be interpreted as a pack of parameters named kind that conform to T, so that syntax is already not viable. But you get the idea.)

13 Likes

Sure variadic generic enums is another hole to be filled, and I welcome it.

However, it's pretty heavy for a lot of one-offs, where I just need a function to take either a Hashable or Void. Or I need either a Decimal or a FloatingPoint. You know, all the same cases where a tuple is much more light weight to spell out than making a struct.

But I'm not too versed in the intricacies of implementation here, speaking strictly from a point of view of usability at call sites and simplifying the mental model by filling non-spellable gaps. I might be asking too much.

3 Likes

I was really in need of this on multiple occasions, even considered to pitch this myself at some point.

The latest case was with my parser combinator library, where I really wanted AnyOf { A(); B() } to return (A.Result | B.Result).

Another good use case could be with the new typed throws:

turnaround<E1, E2>(
    frobnicate: () throws(E1) -> Void,
    defrobnicate: () throws(E2) -> Void
) throws(E1 | E2)

//Or

foo<E>(bar: () throws(E) -> Void) throws(E | InternalFooError) { ...

The matching syntax should resemble those for regular enums, except sometimes labels are implicit/numerical:

switch (10 |) as (Int, Float) {
    case .0(let intVal): break
    case .1(let floatVal): break 
}

// or

switch (float: 20.0) as (int: Int, float: Float) {
    case .int(let intVal): break
    case .float(let floatVal): break 
}
4 Likes

These are examples of already-identified limitations of the language that would be much sooner solved than union types:

Void should conform to Hashable (it's already an approved but expired Evolution proposal), and we should have a modern decimal floating-point type that conforms to FloatingPoint (it's an open issue in Swift Numerics; and indeed, that's why we have BinaryFloatingPoint as a refinement to begin with).

8 Likes

What about use cases outlined by @glukianets above?

1 Like

Similar to earlier replies, I could see a syntax sugar that embraces enums with associated values:

/// `#enum(String, Int)` generates an enum.
// invisible to the rest of the code somehow?
enum _E {
  case string(String)
  case int(Int)
}

func isEven(x: #enum(String, Int)) -> Bool {
  switch x {
  case let .string(string):
    string.count.isMultiple(of: 2)
  case let .int(int):
    int.isMultiple(of: 2)
  }
}
isEven(x: "Hello")
isEven(x: 2)

While I do want to see union types, in particular for handling errors.

I do not think prompters on these should be inferred to be nill if the value is not of that type.

Instead I think when operating on the union of the type you can only access properties/functions that have identical call signatures. Then you can use is or as type checks for branching type specific code.

You could imagine impmenteing this today within swift by having a pre-compiler step ad hock creates protools for any union it spots and populate the protol with all the methods/properties the types have in common with each other.

eg

func speak(_ animal: Cat | Dog) {
    if animal is Cat.self {
        // animal is inferred as Cat
        animal.meow()
    } else {
        // animal is inferred as Dog
        animal.bark()
    }
}

Could be converted pre compile into

private protocol _CatOrDog {
    var type: String { get }
}

private extension Cat:  _CatOrDog  {}
private extension Dog:  _CatOrDog  {}

func speak(_ animal: _CatOrDog) {
    if animal is Cat.self {
        // animal is inferred as Cat
        animal.meow()
    } else {
        // animal is inferred as Dog
        animal.bark()
    }
}