[Pitch] Narrowed Any

Pitch: Narrowed Any β€” closed-class-of-conformers existentials with A | B

The "type-only unions" line of pitches has bounced off this forum many times on the same pre-judgements (TypeScript-style erasure, structural-union member synthesis, unsoundness with nominal typing). The shape below is deliberately not any of those: a closed-class-of-conformers existential that reuses Swift's existing any P machinery and asks the type system to learn nothing new.

Below is the full v1 surface syntax. The formal proposal is long (, will be renamed SE-NNNN-… at review) β€” for design rationale, alternatives considered, anticipated objections, and full implementation details I'd recommend reading it directly rather than re-doing the prose here. Prototype: miku1958/swift branch narrowed-any/phase1-poc.

Reading note. This is not TypeScript-style structural unions, not a sum / coproduct, not an anonymous enum. It is Any constrained to a closed list of dynamic types β€” same existential machinery already shipped for any P, retargeted at a finite list of leaves rather than a single protocol's conformer set. Layout-equivalent to any; no new metadata kind, no new ABI category, no runtime change, no OS upgrade to adopt.


v1 syntax surface

// 1. Values: `A | B` is `Any` narrowed to a closed set of dynamic types.
let v: Int | String = 42                              // OK β€” leaf injection (only implicit move)
let w: Int | String = "hi"                            // OK β€” same
let x: Int | String = 3.14                            // β›” Double is not a leaf

// 2. Pattern matching is exhaustive over the closed leaf set β€” no `default` needed.
switch v {
case let n as Int:    print("int: \(n)")
case let s as String: print("str: \(s)")
}

// 3. Typed throws across heterogeneous error domains (the headline use case).
func loadUser() throws(NetworkError | DecodingError | AuthError) -> User { … }

do {
    let u = try loadUser()
} catch let e as NetworkError  { … }
  catch let e as DecodingError { … }
  catch let e as AuthError     { … }   // exhaustive β€” no trailing `default` / `catch`

// 4. Generic constraints β€” same-type degraded form.
func process<T>(_ error: T) where T == NetworkError | DecodingError { … }
//                                ^^^^ note: == not : β€” the colon form is parser-rejected
//                                in v1 and reserved for the True set-membership
//                                future direction. Today's code uses == cleanly.

// 5. Untagged Codable round-trip β€” declaration order drives decode try-order.
typealias V = Int | String
let v1: V = "hello"
let json = try JSONEncoder().encode(v1)               // β†’ "hello"  (just the leaf β€” no envelope, no `_kind`)
let back = try JSONDecoder().decode(V.self, from: json)  // tries Int, then String

// 6. Composes anywhere a type is expected.
let arr:    [Int | String]            = [1, "two", 3]
let lookup: [String: Int | Double]    = ["pi": 3.14]
let make:   (Int | String) -> Void    = { _ in }
extension Array where Element == Int | String { … }

Cross-shape conversion

let a: Int | String = 42

// Total cast β€” same leaf set, different spelling: free SIL relabel.
let b: String | Int = a                          // β›” implicit cross-shape β€” fix-it: ` as String | Int`
let b: String | Int = a as String | Int          // βœ…

// Partial overlap β†’ `as?` / `as!`. Runtime decides per value.
let c: (Int | Double) | String = "hi"
if let s = c as? Int | String { print(s) }       // .some("hi")

// Class-hierarchy subtyping at the leaf level β€” total when every source leaf
// subtypes some target leaf, even when no leaf names match.
let pet: Dog | Cat = ...
let animal = pet as Animal                       // βœ… Dog: Animal, Cat: Animal

// Disjoint leaf sets β€” hard error in `as` / `as?` / `as!`. `is` stays a warning.
let v: Int | Double = 7
v as? String                                      // β›” error (Int|Double ∩ String = βˆ…)
_ = v is String                                   // ⚠ warning β€” well-typed false

Container extensions and per-element costs

extension Array where Element == Int | String { func summary() -> String { … } }

[1, "two"]    .summary()                          // βœ… ys: [Int | String] β€” exact match
[1, 2]        .summary()                          // β›” leaf-typed [Int] β€” fix-it: ` as [Int | String]`
(["a", 7] as [Int | String]).summary()            // βœ… cross-spelling β€” runtime-free SIL relabel
([1, 2] as [Int | String]).summary()              // βœ… leaf injection β€” O(N) per-element wrap
Reshape Cost Memory peak
Cross-spelling [A|B] β†’ [B|A] O(1) SIL relabel unchanged
Leaf injection [A] β†’ [A|B] O(N) per-element wrap ~4Γ— (8 β†’ 32-byte stride)
Narrowed β†’ leaf via as? O(N) walk + dynamic check 1Γ— new buffer or 0

Inhabited-subset rule for Never leaves

func mayThrow() throws(SomeError | Never) -> Int { … }   // behaves like throws(SomeError)
func nothrows() throws(Never | Never) -> Int { 99 }      // non-throwing β€” multi-leaf SE-0413
let r = try mayThrow()
let n = nothrows()                                        // βœ… no try

switch (v: Int | Never) {                                 // no `case _ as Never:` arm needed
case let n as Int: print(n)
}

Type identity is preserved: A | Never and A have different mangled names, signatures, and witness identities. The inhabited-subset rule governs only call-site reachability, not type identity.


The five load-bearing rules

  1. Leaf injection is the only implicit move. A leaf-typed value flows into a narrowed-Any declaration that lists that leaf, anywhere in the recursive expansion. Every other narrowed-Any ↔ narrowed-Any conversion needs explicit as / as? / as!.
  2. Spelling is identity. Int | String β‰  String | Int β‰  (Int | String) | Bool. Different spellings are different types with different mangled names. The user's chosen spelling drives Codable try-order, switch-completion order, and witness selection β€” silently normalising would silently change behaviour.
  3. Depth-1 principle: structurally nested, behaviourally flat. (A | B) | C has outer leaves {A | B, C}, but switch / leaf injection / as? walk the deep leaf set β€” three flat arms, not "narrow first, then expand". Same as Int?? over Optional. SwiftUI's _ConditionalContent<_ConditionalContent<A,B>,C> already exhibits this; replacing it with T | F retires the wrapper struct.
  4. No normalisation. T | T, Cat | Animal (where Cat: Animal), T | T? are all legal and stay as written.
  5. Cross-shape as follows class-hierarchy convention. as for total casts (source leaves βŠ† target leaves), as? / as! for partial overlap, hard error for disjoint (stricter than current Swift's 5 as? String warning, since the closed leaf set turns "no type in common" into a static fact).

The full design β€” including why each classical type-union law (commutativity, associativity, idempotence, absorption, optional-collapse) is deliberately rejected, the typed-throws O(N+M) composition argument, the conformance synthesis story, debugger-interaction notes for Codable, and the full set of objections answered β€” is in the proposal. There's enough nuance there that re-doing it as forum prose here would lose precision.


Implementation status

covers runtime-positive cases (single-file %target-run-simple-swift plus a split-file cross_module.swift + %target-run) and a verify-mode diagnostics.swift. Companion swift-syntax fork at miku1958/swift-syntax (branch narrowed-any/syntax-sync).

Working today: parser, AST node, mangling (XN), .swiftmodule / .swiftinterface round-trip; cross-shape as / as? / as! via swift_dynamicCast; pattern matching exhaustiveness; self-conforming Error / Sendable / marker protocols; per-witness dispatch for Hashable / Equatable / Comparable / CustomStringConvertible / Encodable / Decodable (JSONEncoder().encode(v), Set<Int | String>, < over Int | Double, \(v) interpolation β€” all work, no as! any P needed); untagged Codable round-trip; typed throws end-to-end (per-leaf try-propagation, inhabited-subset Never rule); where T == A | B (colon form parser-rejected); Set / Dict / Array / KeyPath / reflection; -O SILOptimizer integration.

Known v1 gaps (proposal Β§"Implementation status" has the full breakdown):

  • Per-element leaf injection auto-fix-it at the extension boundary β€” landed; the implicit form (no cast at all) is the deferred follow-up.
  • Cross-spelling fix-it β€” fires for leaf-injection, not yet for cross-spelling (separate Sema follow-up; cast itself works).
  • Strict-invariant rule's function-type axis still silent β€” the three container sub-cases ([A] β†’ [A|B], [A|B] β†’ [Any], Set<A> β†’ Set<A|B>) now error correctly; function-type parameter narrowing ((Int) -> Void = f for f: (Int|String) -> Void) is the remaining v1-blocking item.

Deferred to focused follow-ups: direct member access on a narrowed-Any value (v.description); generalised per-narrowed-Any witness emission for user protocols; witness-merge; variance at the witness boundary; order-insensitive marker; true set-membership; SourceKit auto-cast completion. ~10 items total β€” see Future directions for the list.


Asks for the forum

  1. Is spelling-as-identity the right axiom (A | B β‰  B | A, (A | B) | C β‰  A | B | C), or should v1 normalise / flatten? The proposal argues for spelling-as-identity to make Codable try-order, witness selection, and mangling tractable β€” cost is one explicit as when reshaping.
  2. Does untagged Codable match library-author needs, or is _kind-tagged the right default? v1 ships the untagged synthesis; user-override extension Int | String: Codable { ... } is paired with the broader extending-narrowed-Any-directly follow-up.
  3. Typed throws is the killer use case β€” does throws(NetErr | DecodeErr | AuthErr) with per-leaf catch arms (O(N+M) instead of O(NΓ—M) wrapper enums) match how you'd expect typed throws to compose across libraries?
  4. Are there type positions beyond {value, constraint, pattern, throws} where you'd expect A | B? The proposal sweeps function-type, tuple, container, and generic-argument positions β€” flag misses.
  5. The prototype reuses Any's singleton runtime metadata for ABI-additive evolution (no OS upgrade, back-deploys to any Swift-supporting target). One concrete consequence: Set<Int | String>.union/intersection-style collection-arithmetic ops crash because the runtime conformance walk reads element-type metadata as Any β€” basic Set<Int | String> (construct, contains, deduplicate) works fine. Acceptable v1 trade-off, or should narrowed-Any get distinct metadata?
  6. Of the ~10 deferred follow-ups in Future directions, any you'd expect to ship with v1 rather than as a follow-up?

Discussion welcome before the formal pitch lands as SE-NNNN.

15 Likes

It's worth noting that the existing A & B is not nominal, as shown when you try to extend it:

extension (Foo & Bar) {}
// error: non-nominal type 'Bar & Foo' cannot be extended [#NominalTypes]
1 Like
  1. When I have multiple types that need to implement the same set of methods simultaneously, but I don't want to introduce a protocol to share those methods β€” because that protocol could be adopted by other types β€” I can use extension A | B { ... } instead.
  2. The custom Codable implementation for narrowed Any types mentioned in the body of the proposal.

Posters have to bear responsibility for the text that they put up under their name, even if it is AI-generated. This goes doubly for documents purporting to tackle a difficult area where you're asking the community to read a substantial text just to engage at all.


The linked text starts off saying:

Reading note. This document deliberately avoids the label union types for what it proposes. [...] The feature here is not a structural union, not a sum / coproduct, and not an anonymous enum.

Then, it proceeds to say that the feature isn't being named a type union, but

The semantics described below carry over verbatim regardless of which name wins.

Then, by the middle, it says:

This is a direct port of Scala 3's join, a thirty-year-old idea that has shipped in Ceylon, Scala 3, Crystal, Python (PEP 604), and TypeScript with no language-design controversy.

The link goes directly to a page about Scala 3 union types, which notably are distinct from least upper bound type joins available in Scala 2. Scala 3 union types, further, are semantically untagged sum types, and they are unboxed and commutativeβ€”behaviors which notably this purported pitch (far from directly porting) explicitly rejects.


I don't know if what's implemented is workable, but the text doubles over on itself and I'm not sure we're well served basically being proofreaders for AI hallucinations. Sorry if this is harsh: for what it's worth, my Claude is telling me I'm right to push back.

20 Likes

I agree with @bbrk24, what you are proposing is undoubtedly a structural type, just not a traditional sum type. The additional constraints you’ve added seem motivated solely to placate stylistic and philosophical objections to previous pitches, not because they make your pitch better.

Case in point, if A | B β‰  B | A, doesn’t that fail to address your motivating use case of composing error types of underlying function calls without imposing constraints on the original function declarations?

func doSomething() throws(NetworkError | FilesystemError) { … }

func doAnotherThing() throws(FilesystemError | NetworkError) { … }

func doBothThings() throws(???) {
  try doSomething()
  try doAnotherThing()
}

I think your argument is much stronger without the contortions to placate objections in advance. You have a solid, real-world motivation, and have done a lot of work to explore solving it. If we really do need a sum type to solve this issue, then by golly we should just build a sum type.

3 Likes

This would give up some of the performance motivations of SE-0413 right? For embedded I don't think that dynamic casting would work well in general (I thought that was what was being avoided with typed throws). I think the tagged-union layout for small POD leaf sets that you mention in future directions would work there, embedded swift might be worth a brief mention. It does seem unfortunate to make typed throws more ergonomic in a way that cannot be fully supported in the case where typed throws is the only option.

1 Like

@xwu thank you for pointing this out. Both inconsistencies do indeed exist, and the responsibility is mine. I had AI simplify the full proposal into a pitch, and I did not revise it carefully enough myself. Before opening the PR, I will make two explicit corrections in the proposal text:

  1. The reason the Reading section rejects the label "union types" is that it could lead reviewers toward a TypeScript-colored interpretation, whereas the statement in Naming that "regardless of which name is ultimately chosen, the semantics will remain the same" refers to making a final naming choice among candidate terms such as Narrowed Any, Closed Type Set, and Type Union. These are decisions at two different levels. The original text conflated them, which is a problem of unclear wording, not an actual design contradiction. I will rewrite this part to distinguish them clearly.

  2. The phrase "directly ports Scala 3's join" was overstated. In reality, it only borrows the join computation for the visible interface (members) of A | B. It does not port the broader semantics of Scala 3 union types, such as commutativity, unboxing, or erasure of tags, and those are precisely what this proposal rejects. The pitch omitted this part. A more accurate description would be "borrows the concept of join from Scala 3" rather than "directly ports Scala 3's join." I will revise this wording.

@ksluder , one key point of this proposal is "spelling is identity." This affects type-identity boundaries: cross-shape assignment at the value level, protocol conformance at witness boundaries, and symbol adaptation (mangling). It does not affect the try-propagation boundary; try propagation is a per-leaf flow check: every leaf type that an internal call may throw must be included in the outer declared set.

So your example can compose without any forced casting, regardless of which spelling the user chooses for the outer layer:

func doSomething() throws(NetworkError | FilesystemError) { … }
func doAnotherThing() throws(FilesystemError | NetworkError) { … }

func doBothThings() throws(NetworkError | FilesystemError) {
    try doSomething()        // internal leaves {NetworkError, FilesystemError} βŠ† outer set βœ“
    try doAnotherThing()     // internal leaves {FilesystemError, NetworkError} βŠ† outer set βœ“
}

At runtime, the thrown value is always a concrete single leaf. No matter which spelling either internal function uses, both leaves are in the outer declared set. This propagation rule is consistent with the rule SE-0413 uses in throws(SpecificError) β†’ throws(any Error); it is simply generalized from "is a subtype of any Error" to "is a member of the outer closed leaf set."

The scenario where "spelling is identity" truly introduces casting cost is: assigning a narrowed-Any value in one spelling to a binding in another spelling, or using throws(B | A) to implement a protocol method whose signature is throws(A | B) (the witness-boundary scenario). These are type-identity issues, not propagation issues. Your example is the latter, try propagation, and it works in v1 without any workaround.

The proposal needs to make this distinction clearer; that is my documentation correction responsibility, not a design change.

(Please keep in mind I'm asking this question as someone that has a shallow understanding of pretty much every part of this concept.)

Can the type Int | String be syntactic sugar for a compiler-generated enum?

typealias V = Int | String
let v: V = 42

// is equivalent to:

enum CompilerGeneratedV {
    case int(Int)
    case string(String)
}

let v: CompilerGeneratedV = .int(42)

Where does this break down? Has such a thing been suggested before?

3 Likes

This has been suggested before, and where it breaks down is that we really want A | Never or Never | A to just be A.

public enum Err<First: Error, Second: Error>: Error {
  case first(First), second(Second)
}

// In practice Err<Never, Never> would be the result of generic composition
func nothrows() throws(Err<Never, Never>) -> Int {
  69_105
}

do {
  print(nothrows()) // error: call can throw but is not marked with 'try'
}
3 Likes

Huh this is very interesting, thank you!

Again, apologies. I follow the example, but I do not fully understand why that illustrates that A | Never could not be simplified to A.

You asked if it could just be a compiler generated enum, but because we need A | Never to be Never it has to be more complicated than that. Since generics are not templates, we need to be able to define these error enums at runtime, and still collapse Never. That's a very complicated problem to solve.

Update

Pitch
New: Classical type-union laws are deliberately rejected @xwu

Proposal
Naming correction @xwu
New example for Try-propagation is per-leaf, not per-spelling @ksluder

@aviva β€” Thanks for the additional context. Two threads to pull on:

The SE-0413 perf story. SE-0413's perf wins land where the function has one leaf β€” fixed-size, stack-resident, no boxing, no metadata pointer at the throw site. Once a function composes multiple error sources via a wrapper enum (enum AppErr { case net(NetErr); case fs(FsErr); … }), the multi-case enum payload is back to max payload size + discriminator, which is roughly the same regime narrowed-Any lands in under the Tagged-union layout future direction. So narrowed-Any doesn't really give up SE-0413's perf wins for multi-source β€” those were already given up the moment you needed multiple error sources at all. What v1 does give up vs a hand-written wrapper enum is the existential indirection (Any-singleton metadata pointer + the swift_dynamicCast runtime path on the catch arm) β€” real cost, real reason to flag.

For Embedded Swift specifically. You're right: the v1 layout (Any singleton metadata + swift_dynamicCast) is the wrong shape for Embedded, where those mechanisms are restricted. The Tagged-union layout future direction is exactly the answer. I just pushed a clarification (commit 36cefea) framing it explicitly as the Embedded story:

  • This proposal (full-Swift, non-Embedded): existential layout + swift_dynamicCast. The O(N+M) ergonomic improvement over wrapper enums lands immediately for non-Embedded targets.
  • A follow-up SE proposal (Embedded Swift unblocked): tagged-union layout for small POD leaf sets β€” discriminator + payload local emission, no swift_dynamicCast on the catch path. Same regime as a wrapper enum on Embedded, just without the named-wrapper-enum tax.

The transformation is a local IRGen pass β€” no ABI changes, no new metadata kinds, no language-rule changes. It can ship as a separate SE proposal once the Embedded-Swift narrowed-Any story is review-ready. The language design for narrowed-Any is already committed to making this work for Embedded; the implementation timeline is the only question.

If you have a concrete Embedded use case where A | B would land β€” or a list of typed-throws shapes you'd want to compose β€” I'd genuinely value that as input on whether the v1+1 work is the right priority among the deferred items, or whether parts of it should be pulled forward.

short answer to @mattie: the enum desugar works for the easy cases but breaks where narrowed-Any is built to handle β€” per-instantiation runtime types, cross-library spelling tolerance at try-propagation, and inhabitance-driven simplification of Never leaves. @Nobody1707 has the heart of it right. Three places where the compiler-generated enum desugar breaks down for what this pitch is going after, and the third is the most relevant to your Never thread:

  1. Runtime layout. A per-instantiation generated enum gives every Err<E1, E2> substitution its own tagged-union metadata, witness tables, and mangled name. Narrowed-Any uses the existing Any singleton metadata plus a static closed-class-of-conformers table emitted per declaration site β€” no per-substitution enum, no new metadata kind, no new ABI category. The feature back-deploys to any Swift-supporting OS without a runtime upgrade. (The proposal's Implications on adoption spells this out.)

  2. Cross-library identity. Two libraries that both spell (NetworkError | DecodingError) get the same type (same mangled name), because identity is the leaf list, not a wrapping enum declaration the user invented. Different spellings (NetworkError | DecodingError vs DecodingError | NetworkError) are different types under spelling-as-identity, but they compose at try sites without explicit cast β€” propagation is per-leaf flow (proposal: Try-propagation is per-leaf, not per-spelling). With per-instantiation enums, the third-library composer has to invent a third enum to merge the prior two; that's the O(NΓ—M) blow-up the pitch is trying to fix.

  3. The Never question β€” exactly @Nobody1707's point, and where narrowed-Any also differs from the enum desugar. With a real enum, the compiler cannot see "all cases are uninhabited" because the case constructors are first-class declarations: Err<Never, Never> is a real type with two cases, even though both are unreachable, and the type-checker has nowhere to hang the Never-collapse rule. With narrowed-Any, the leaf set is visible to the type-checker at the use site β€” so the compiler can extend SE-0413's existing throws(Never) non-throwing rule mechanically:

  • throws(A | Never): one inhabited leaf (A) β†’ operationally throws(A), try still required.
  • throws(Never | Never): all leaves uninhabited β†’ operationally non-throwing, no try required at call site (same as throws(Never) today).
  • throws(Err<Never, Never>) in your example: not directly applicable since this is the enum desugar, but the narrowed-Any analogue would be throws(Never) after Never-collapse, which SE-0413 already handles.

Pushed the multi-leaf Never-collapse paragraph to the proposal: Uninhabited (Never) leaves and the inhabited-subset rule (commit e3e9a35). The rule is mechanical and consistent with SE-0413: strip Never leaves from the throws set, then apply the existing throws-set-empty check at the call site.

Thanks for all the hard work on this. It seems this pitch fills all the gaps that previous attempts had.

1 Like

Does this feature violate Swift's design principle that types should not be dependent on the compilation of other types (or whatever the precise term for it is)? Since A | B requires knowledge of both types, and those types could live in separate modules from the consumer, it seems like such usage would require both modules to be fully compiled before we could even parse whether the expression is possible. I'm pretty sure Swift has avoided features that require something like that in the past.

Also, could an alternate name for this be "anonymous protocols", especially if you reused &? A & B generates a protocol based on the common interface between the two types.

And given the need to generate a common interface, it seems like this could have scaling issues for types with large API surfaces, especially when they're both internal. If I did Array<T> & Set<T> (is that syntax even possible), wouldn't it have to calculate the intersection of all the collection protocols?

1 Like

Why would that be any different than P & Q where P and Q are protocols that come from different modules than the consumer?

Without opining on the proposed feature itself, there's no parsing problem here. An example like f(x: A | B) would just parse as something like TypeUnionSyntax(IdentifierTypeSyntax("A"), IdentifierTypeSyntax("B")) and it would get resolved by the type checker.

3 Likes

Yeah, realized that after I posted. I thought the dynamic interface might pose an issue, but that's not a syntax question.

1 Like

Added Result builder as an improvable example

[Pitch] Five design rules

[Proposal] real-world-buildeither-example

[Proposal] Result builders: simplifying buildEither and the _ConditionalContent ladder