Replacing the Type Checker

String concatenation is a nice surface attack vector.

I have the personal belief that "you're holding it wrong!" is a poor excuse, especially when languages for similar purposes (eg. React, Flutter, Jetpack Compose) can handle equivalent code without issue. I think it's worth it to at least consider how a Swift with a different type system would work.

I don't have much in-depth knowledge about swift's type constraint resolving system, just that it is bidirectional and that leads to huge compile times. If we had a unidirectional type system instead, where the types of values are solved from inside-out, left to right.

func identity<T>(_ value: T) -> T { value }

var myDouble: Double = identity(0.5)

The 0.5 would be resolved as a Double (default for float literals), the identity would resolve as a Double as well, and finally myDouble is a double so everything works out. However, this line that works in Swift 6 suddenly won't work in Hypothetical-Swift:

var myFloat1: Float = identity(0.5) // fails to compile
var myFloat2: Float = identity(Float(0.5)) // compiles

var myFloat3: Float = 0.5 // hell, this might even fail to compile

since identity would be of type Double. This would also completely break almost all [something]Representable protocols.

===

However, this method drastically simplifies other complex terms, since after the type checker "verifies" the type for a given statement, it treats it as truth to compare future statements to. Taking the HTTP builder as an example:

let url = "http://" + username   // string
            + ":" + password     // string
            + "@" + address      // string
            + "/api/" + channel  // int!?
            + "/picture"

Hypothetical-Swift would see that the content before the fourth plus is a String, and that there is no + overload where the rhs is an integer. No need to retry a billion StringRepresentables.

From my (naive) point of view, this doesn't even mess up SwiftUI that much. SwiftUI view types are built from the bottom-up (possible order of type resolving pointed out)

HStack {
    ForEach(0..<5 /*(1. Range<Int>)*/) { index in /*(2: Int)*/
        Text("Hello \(index)!" /*(3. string)*/) /*(4. Text)*/
    } /*(4: (Int) -> Text)*/
} /*(5. ForEach<Range<Int>, Text>)*/
/*(6. HStack<stuff here>)*/

This to me feels an acceptable amount of sacrificed "magic", esp if something like C's 0.5f to indicate the type of literals is added too.

8 Likes

I agree in general about diagnostics, so don’t want to argue in general with the statements.

What I find a bit off is that it is too focused on SwiftUI - after all, this is just a private Apple framework, and I bet this is largely up to their priorities whether or not to address this part in the language.

I also can’t fully agree that SwiftUI is simple to reason from the type-checker perspective. It is hard to reason by yourself — to be fair, columns extraction to the property is not an easy one to comprehend for people, formalising it with generics system altogether seems like a quite demanding task.

Instead, as the general premise of the discussion is actually sympathetic to many, I’d suggest that focus can be shift a bit to the Swift’s general issues in type checking, like in the example of strings concatenation. That seems to be more addressable issue, that simply requires attention. So the question then would be is how it can be addressed.

Well, SwiftUI is where the type-checker really performs poorly. And SwiftUI is the primary way Apple is pushing devs to build apps—all the documentation examples, WWDC videos, etc. now show just SwiftUI code.

And while Swift is used for more than just app development on Apple platforms, it’s kind of the “80/20 rule”. 80% of existent Swift code is probably apps. The other 20% is libraries, Vapor, etc. So, yes, focusing on SwiftUI seems wise to me: it’s the primary way 80% of Swift developers are going to use the language, especially in the future.

7 Likes

Going unidirectional would lose inference for lambda argument types, AIUI. So instead of:

[1, 2, 3].map { $0*2 }

you have to write:

[1,2,3].map { (x: Int) in x*2 }

You'd also lose the concise enum syntax. So instead of:

myFunc(.MyEnumCase)

you'd have to write:

myFunc(MyCoolEnum.MyEnumCase)

I'm definitely ok with losing the latter, but I'd have to do more research on the former. I'm going to try going through my code and spelling out the lambda arguments to see how bad it is.

More info here: "unable to type-check this expression in a resonable time" - #3 by audulus

That specific invalid string concatenation expression hits the scope limit:

let s = ""
let n = 0

let url = "" + s + "" + s + "" + s + "" + n + ""

Now, there are two general approaches to speeding up the constraint solver:

  • You can attempt choices in a different order, or skip some choices entirely, to find the solution in fewer steps.
  • You can optimize the data structures and general logic to minimize the amount of work that has to be done for each choice.

There were some improvements recently mostly of the second variety. The above expression still fails, which is of course unfortunate, but it fails faster. It took the Swift 6 compiler about 42 seconds to hit the scope limit here on my machine, but in Swift 6.1 it gets there in only 11 seconds. A compiler built from main takes 10 seconds today (with no asserts it would be a bit faster still). This also means that there are other expressions that succeed close to the limit, and they now succeed faster, etc.

14 Likes

I don't know how the type-checker scopes its work currently, but would it be possible to have it "re-use" the last solution it found for parts of the code that have not changed since the last build? (I know the compiler does this on a file-level, but would it be possible to extend that to more specific scopes?)

For instance, in my Table example, nothing in the Table itself changed from the last successful build to the one that failed. The only change occurred inside .contextMenu(selectionFor:) where I incorrectly declared a type. Could the type-checker be improved to scope its work to only blocks that have changed? Could it re-use the solution it previously found for Table and work only on the block that has changed since the last run (here, the viewModifier)?

1 Like

Another simple example:

This is a function inside a View. The compiler immediately flags the issue: "you forgot the id parameter name in this function call, genius."

But if you take that openWindow(ProCueApp.mainWindowIdentifier) line and stick it directly in the action closure of a Button inside body, then the compiler falls down and tells you it can't type-check in reasonable time. It has no idea what's wrong.

If you fix this, you'll VASTLY improve the quality of life for the 80% of Swift developers who use the language to build apps.

7 Likes

That's really why we're all here today. Nobody has figured out yet how to write a type checker that runs in polynomial time for a language with both lambda expressions and type-based overloading ;-)

10 Likes

For better or worse, bidirectional type inference means that doing things this way would (potentially) yield different results when run incrementally vs. from scratch. Overload resolution and ranking means that a change inside contextMenu(selectionFor:) could result in different type resolution ~anywhere in the entire expression.

3 Likes

Finding ways to improve Swift’s type checking is going to be my master’s in a few years (or personal project if I can’t afford to do a master’s).

I believe there’s certainly ways to improve the algorithm by taking shortcuts, making guesses, cutting down search space and probably more.

I’ve already started researching type systems and how other compilers do type checking, of course I’ve been researching Swift’s internals for a while!

I believe we could get our cake and eat it too! Improve type checker performance and diagnostics without sacrificing any language features.

14 Likes

It's like solving a Sudoku. You try to write down a number in a blank square, and then you check that the result is still a valid board. If so, you try to fill in another square, and so on. If at some point you get stuck, you have to backtrack by erasing one or more previously filled in squares so you can attempt to place a number somewhere else. If you're lucky and you make perfect guesses at each step, you can fill in the whole board without backtracking. At the other extreme you can imagine attempting every possible path to a solution, which will take a very long time. (Of course in our case, they may be no solution, and we need to be able to deduce that without exploring every possible partial solution too.)

27 Likes

IMHO this is totally fine for it to just fail. Fail and inform that result of expression needs to be explicitly defined. I would say that in certain cases it might even be undesirable to have implicitly inferred type there. Over time I think people lean over explicitly specifying types anyway — at least that’s both my experience and observations.

I know this discussion is focused around SwiftUI because it's where most developers are experiencing the pain (most shark attacks happen in shallow water).

but the real culprit here is Result Builders, isn't it? SwiftUI just implements the languages tools for building DSLs.

There are popular libraries that suggest putting a lot of application logic in Result Builder contexts. The same type checking inference issues pop up in those contexts. It also seems like there isn't really a way to give the type checker reasonable hints about the contents that improves the issues.

It seems like the complexity of the use cases that people want to use them for has outpaced what the compiler can handle. I would love to understand what tradeoffs could be made to improve the type checking performance of Result Builders, and where something like that lives on the priorities for the swift team

20 Likes

Yeah, bad diagnostics inside result builder is a long-lasting problem, specially for new developers that are much more compiler dependent than experienced developers that can spot the problem when the compiler can't. I have myself reported two issues while using SwiftUI:

But reading the PR to solve the second one that @gregtitus opened (merge this, please :smiling_face_with_tear:) at least on this case the problem is @dynamicMemberLookup so maybe result builder gets more blame than deserved.

The issue is speed of finding the problem. Sure, experienced devs can backtrack and find the tiny typo or the AI-suggested autocomplete that doesn’t exist. But it takes time to do that and it takes you out of your flow, which reduces overall productivity. That’s true for programmers of all experience levels.

Given enough time, I can always find the problem. It’s just a matter of how close I come to throwing my Mac out a window before I do.

10 Likes

If only result builders were proper macros instead of “compiler magic”, or if Swift had macros 5+ years ago, developers could expand the macro to see an ambiguous issue more clearly.

3 Likes

This may be getting off on a tangent, but I think macro-based result builders would require a solution to the problem I ran into with function body macros - you can't get good diagnostics for any errors within the function because the compiler doesn't recognize any correlation between the code I write and what the macro transforms it into. There's no way to express what that correlation is.

4 Likes

Just a question, how does Swift type checker compare to Kotlin?
Kotlin also likes to use lambdas and type inference (and relies on them to build DSLs as well). Yet I haven't seen its type checker (which is more restrictive than Swift to be fair, as more type annotations are explicitly required) fail so badly, or having a problem to deduce a type in reasonable time (which is weird, as type checker often breaks when the types of lambda parameters are already known - even though the situation should have improved according to @Slava_Pestov.

It also doesn't fail when evaluating expressions. How does Kotlin things differently, so they don't hit "no polynomial time algorithm for general type inference" wall (at least not as often as Swift does), and yet their type inference is pretty useful and does reduce unnecessary type annotations?
Is it just a side effect of Kotlin having a simpler type system (for example, no protocols) than in Swift?
If there is more to that, perhaps a similar strategy could be (optionally and gradually) adopted for Swift? (as for breaking code - it is trivial for an IDE to insert type annotations, and could done automatically and mostly just work and migration would be much easier than migration to Swift 6)

8 Likes

One of the big issues here for open source work, is that deciding how the Swift type checker ought to work as a decision-making process isn't very decomposable. Making a change to strategy or to specific diagnoses can improve the kind of code that you are aiming at, while at the same time making some other kind of code worse (in compile time or in error diagnosis accuracy).

And sufficiently proving that either you aren't making any other things worse, or that the tradeoff is worthwhile is very difficult. It often isn't the sort of thing where you can just read through a PR and say "yep, that's right", because a lot is a judgement call. Is this kind of programmer error more or less likely than this other kind?

5 Likes