Add Type Narrowing to Swift

I'd like to see some type narrowing of some kind added to Swift (Typescript's implementation is pretty good).
I haven't been using Swift for a very long yet (only a few months of regular usage). Still, I've already noticed the boilerplate introduced by the lack of type narrowing in this short period, with Optional<Type> especially.

let myString: String? = ""

// Here, you have to assert that the variable is not nil every time it's used,
// even though the compiler should be able to pick it up from context.
if myString != nil {
	print(myString.reversed())
}

// Here, you have to either come up with a new name (naming is hard),
// or you have to shadow the variable, meaning you lose access to the original value.
if let mySafeString = myString {
	print(mySafeString.reversed())
}

protocol Animal {
	var name: String { get }
}
struct Cat: Animal {
	var name: String
	func meow() {}
}
struct Dog: Animal {
	var name: String
	func bark() {}
}

let myDog: Animal = Dog(name: "Bob")

if myDog is Dog {
	// This is an example of where the boilerplate starts to add up.
	(myDog as! Dog).bark()
}

There is also the potential for foot-guns with the first approach if, for example, someone copies some not-nil asserted code from within the != nil check and pastes it somewhere that doesn't have the check around it. This situation is very possible, especially given how easy the ! can be to miss if you aren't looking for it.

let myString: String?

if myString != nil {
	print(myString!.reversed())
}

// This could be an issue
print(myString!.reversed())

In an ideal world where type narrowing exists, we would be able to do this:

if myDog is Dog {
	// The compiler keeps track of what we've already narrowed the type down to,
	// so we don't need to assert its type - in this block, `myDog is Dog` will always be true.
	myDog.bark()
}

Sorry that I can't go into the specifics of how this might be implemented - I'm not too well versed in how compilers work under the hood, but even if it's technically challenging to implement, I feel like this would make life life so much easier for developers and make the language much more intuitive to use.

The optional case already has sugar for its sugar:

let myString: String?

if let myString {
    // myString is String inside here
    print(myString.reversed())
}

Shadowing an optional inside a conditional is usually not a problem.

As for the conversion of an existential to it's underlying type. the best we have right now is:

let myDog: any Animal = Dog(name: Bob)

if let myDog = myDog as? Dog {
    myDog.bark()
}

Which is at least better than doing a test and a conversion separately. Although, that sort of conversion doesn't really come up very often in idiomatic Swift code. Most of the time you'd be calling a protocol requirement instead of a method that only exists on a specific type.

5 Likes

The guard statement (generally) accomplishes what you want:

let string: String? = ""
guard let string = string else {
	// throw, return, fatalError, etc.
}
string.reversed() // works fine, compiler knows we can't have made it here with string being optional

The key is that the compiler has to be able to determine statically that all paths through the code either result in the optional being unwrapped or control flow exiting, otherwise it can't know any more information about the type than declared.

This won't address every case, but it helps shave boilerplate down considerably.

1 Like

Thank you for letting me know about this - now I feel slightly silly that I made this post :sweat_smile:. Thank you to everyone else who replied as well for explaining this to me - sorry if I've wasted some of your time asking about something that I would have known about if I'd done more thourough research.

5 Likes

It’s not a silly question at all! Never hesitate to ask questions. :blush:
I love the typescript narrowing too. It feels excellent- perhaps especially for such a dynamic language.
I love the fact that functions can opt in to the narrowing, such that in the truthy case of ‘if isString(x)’ you can know that x is a string of the function was annotated for that.
Compared to the explicitness of Swift I can see at least one benefit:
When you don’t explicitly bind an unwrapped value, then you don’t need a new variable. In swift you are allowed to ‘shadow’ a variable name, but not in all situations.
So here typescript doesn’t force you to invent new variable names, which is a good thing.

4 Likes

TypeScript really does amaze me with how much it’s managed to bring order to the organically-grown, deliberately-permissive, and ad-hoc overloaded APIs of the JavaScript ecosystems, some of them more than 20 years old. Type narrowing supports the idioms that, for better or worse, have emerged as the, well, idiomatic way to define and use such APIs. And the fact that they’ve managed to do so performantly is a marvel.

But C and Objective-C and Swift APIs usually aren’t like that. C and Objective-C don’t have overloading, but their type systems are strong enough to discourage the sort of ad-hoc overloading JavaScript does instead (and by extension TypeScript).* Swift does have overloading, and doesn’t need to do it ad-hoc at all.

As people have noted, the two main uses for narrowing that apply to Swift are checking Optionals and downcasting. In TypeScript these are both narrowing operations because both are usually represented as taking an anonymous union type and ruling out certain arms of that union. On the flip side, TypeScript tries to preserve JavaScript idioms, so having to convert if x === null to guard const x else would mean fewer people would adopt TypeScript. But neither of those apply to Swift, because Swift did not need to stick as closely to existing syntax, and because Swift doesn’t have anonymous union types. Instead, Swift encourages protocols for factoring out common operations across disparate types, rather than type-switches. That’s a choice with trade-offs, to be sure, but it’s more feasible in Swift than JavaScript because extension methods on Swift types can’t collide at run-time.** So no “prototype pollution”.

Of course, Swift does still think this is useful for Optionals and for enums in general, which is why if let/guard let and switch exist. And to be fair, TypeScript’s system can represent some checks that Swift’s can’t: if x.y !== null has no direct equivalent in Swift for further uses of x, and similarly there’s no way to represent the resulting type X & { y: Y } (rather than y: Y | null) in today’s Swift. But on the flip side, Swift lets you add methods and protocols to the built-in String and Int types, so there’s much less need for common unions like string | number.

* At least with the way Apple has used Objective-C. The original ObjC had sensibilities closer to Smalltalk’s and thus to JavaScript’s, with id-typed parameters and a general expectation of “duck typing”; if NeXT had stayed in that vein, we might have had a different world. But even duck typing is closer to TypeScript’s interfaces than its union types.

** The exception is methods exposed to Objective-C, whose dispatch model is closer to JavaScript’s.

19 Likes