October 17, 2019·5 min read·engineeringswiftcompilerdiagnostics

Swift diagnostics retain the constraints that explain a failure

Swift explains a diagnostic architecture that records failed constraints while solving the rest of an expression. That context lets errors point to the operation that needs correction.

A compiler can know that an expression is invalid without knowing how to explain the mistake. Swift's type checker has sometimes reported an ambiguous expression when the actual problem is a small, local error. Pavel Yaskevich's October 17 architecture overview explains a different approach. The solver records repairs that would let it continue, then uses the resulting context to describe the original failure.

The distinction concerns the compiler's reasoning, not permission to execute repaired source. A hypothetical repair helps identify why an expression fails. The compiler can then produce a diagnostic and, where appropriate, a suggested edit. The architecture began appearing in Swift 5.1, while the article describes further diagnostic improvements being developed for the upcoming Swift 5.2 release.

I care about this because a language's usability depends on what happens while a program is incomplete. Swift's expressiveness is useful, but an error that names the wrong operation makes that expressiveness expensive to work with. A compiler that retains the reason for its failure gives the programmer better evidence.

Inference combines information in both directions

The type-checker design document in the Swift 5.1 sources describes bidirectional inference. Types can flow upward from subexpressions and downward from the expected type of the larger expression. A literal need not receive its final type before the surrounding operation is considered.

That flexibility requires the compiler to solve relationships. A function call connects argument types to parameter types. A protocol requirement constrains a candidate type. An overloaded operator gives the solver several declarations to consider. A type variable represents information the compiler has not yet fixed.

The solver cannot treat every numeric literal as an Int before examining its context. A surrounding declaration may require another literal-compatible type. Likewise, choosing an operator overload can restrict the types of its arguments and its result. The constraints describe those relationships while the solver explores combinations that satisfy them.

When no combination works, the point where a search attempt ends may not explain the programmer's mistake. One overload may fail for a reason irrelevant to the intended operation. Another may nearly fit except for a missing argument label. Selecting the useful explanation requires more information than the fact that the whole expression has no solution.

A hypothetical repair preserves context

The new approach uses constraint fixes to continue after a recognized failure. The fix records the kind of problem and the source relationship involved. Solving the remaining constraints can then reveal the context needed for an actionable diagnostic. The compiler does not have to reconstruct that context from an undifferentiated failure afterward.

A simple call demonstrates why this is useful. This example intentionally contains two mistakes.

func update(_ value: inout Int, label: String) {
    value += 1
}

var value = 0
update(value, "count")

The first argument needs an explicit &, and the second needs its argument label. Recording one missing piece lets the solver continue far enough to identify the other. The corrected call expresses both requirements.

update(&value, label: "count")

The important behavior is that one error does not necessarily erase information about the other. The compiler can retain the expected parameter types and the correspondence between parameters and arguments. That correspondence gives a proposed edit a location and a reason.

A suggestion still does not establish the programmer's intended behavior. Adding & is appropriate if the caller intended to pass mutable storage to this function. Choosing a different overload or function could be the intended repair instead. The diagnostic explains the incompatibility with the selected interpretation, while the developer remains responsible for the program's meaning.

Source locations are relationships within an expression

The design document describes constraint locators that track how constraints arise from the original expression. A locator can identify a function application, then an argument, then a component within that argument. As the solver breaks a relationship into smaller relationships, those paths retain the connection to source.

That matters because an inferred type alone is not a useful editor location. Knowing that a type lacks a protocol conformance does not identify which part of a long expression introduced the requirement. The compiler needs both the requirement and the path connecting it to the expression the developer can edit.

A conversion failure illustrates the difference. The operation may have a valid overload, but one argument has the wrong type for that overload. A useful error can identify that argument and the expected type. A broader complaint about the enclosing operator leaves the programmer to discover the same relationship manually.

I find this a practical improvement to the feedback loop. The compiler already performs substantial reasoning to reject the expression. Reusing that reasoning in its explanation reduces duplicated investigation by the programmer, without asking the language to accept a weaker type contract.

Better diagnostics support compositional APIs

A composed expression can contain several operations whose types depend on each other. The overview demonstrates this with generic and SwiftUI examples. An error in a nested operation may previously have surfaced as a complaint about another type relationship in the enclosing expression. The architecture aims to preserve enough local evidence to name the failing member or argument.

For an API author, this affects how approachable a generic interface becomes. A valid example can look concise while an invalid variation produces a difficult search through inferred types. Diagnostic quality determines how readily a user can move from that invalid variation back to a valid program.

It also changes what a useful compiler regression test needs to establish. Rejecting an invalid program is only one observable requirement. The test can require the error to identify the relevant argument or missing member. A compiler that continues rejecting the program but points elsewhere has preserved acceptance behavior while regressing the development experience.

The October article presents ongoing work, so its examples should not be read as a claim that every improvement is available in every released compiler. The architectural change gives diagnostic implementations access to the failed constraint and its surrounding solution. Individual diagnostics still need to use that information and retain useful behavior as the solver and language evolve.