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
Anyconstrained to a closed list of dynamic types β same existential machinery already shipped forany P, retargeted at a finite list of leaves rather than a single protocol's conformer set. Layout-equivalent toany; 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
- Leaf injection is the only implicit move. A leaf-typed value flows into a narrowed-
Anydeclaration that lists that leaf, anywhere in the recursive expansion. Every other narrowed-Anyβ narrowed-Anyconversion needs explicitas/as?/as!. - 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. - Depth-1 principle: structurally nested, behaviourally flat.
(A | B) | Chas outer leaves{A | B, C}, butswitch/ leaf injection /as?walk the deep leaf set β three flat arms, not "narrow first, then expand". Same asInt??overOptional. SwiftUI's_ConditionalContent<_ConditionalContent<A,B>,C>already exhibits this; replacing it withT | Fretires the wrapper struct. - No normalisation.
T | T,Cat | Animal(whereCat: Animal),T | T?are all legal and stay as written. - Cross-shape
asfollows class-hierarchy convention.asfor total casts (source leaves β target leaves),as?/as!for partial overlap, hard error for disjoint (stricter than current Swift's5 as? Stringwarning, 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 = fforf: (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
- 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 explicitaswhen reshaping. - Does untagged Codable match library-author needs, or is
_kind-tagged the right default? v1 ships the untagged synthesis; user-overrideextension Int | String: Codable { ... }is paired with the broader extending-narrowed-Any-directly follow-up. - Typed throws is the killer use case β does
throws(NetErr | DecodeErr | AuthErr)with per-leaf catch arms (O(N+M)instead ofO(NΓM)wrapper enums) match how you'd expect typed throws to compose across libraries? - 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. - 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 asAnyβ basicSet<Int | String>(construct, contains, deduplicate) works fine. Acceptable v1 trade-off, or should narrowed-Anyget distinct metadata? - 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.