Why does adding a defaulted parameter break clients that were already built, and how do you keep the old symbol?

15.9.1 · L5 · Interview

Predict first

A client built against v1 runs against a v2 library that replaced fetch(id:) with a defaulted version, then one that kept the old symbol:

// === v1/Lib.swift ===
public func fetch(id: Int) -> String { "user-\(id)" }
// === v2/Lib.swift ===
public func fetch(id: Int, timeout: Int = 30) -> String { "user-\(id) (timeout \(timeout))" }
// === v2/LibKeep.swift ===
public func fetch(id: Int, timeout: Int = 30) -> String { "user-\(id) (timeout \(timeout))" }
@_disfavoredOverload public func fetch(id: Int) -> String { fetch(id: id, timeout: 30) }
// === app.swift ===
import Lib
print(fetch(id: 7))
// === run.sh ===
mk() { swiftc -swift-version 5 -parse-as-library -emit-library -emit-module -module-name Lib -enable-library-evolution "$1" -o "$2/libLib.dylib" -emit-module-path "$2/Lib.swiftmodule" -Xlinker -install_name -Xlinker "@rpath/libLib.dylib" 2>&1 | grep error; }
mkdir -p b1 b2 b3 lib lib2
mk v1/Lib.swift b1
swiftc -swift-version 5 app.swift -I b1 -L b1 -lLib -Xlinker -rpath -Xlinker @executable_path/lib -o app 2>&1 | grep error
cp b1/libLib.dylib lib/
echo "built against v1, run with v1: $(./app)"
mk v2/Lib.swift b2
rm -f lib/libLib.dylib; cp b2/libLib.dylib lib/
echo "same binary, library swapped for v2 (default argument added): $(./app 2>&1 | head -2 | tr '\n' ' ')"
mk v2/LibKeep.swift b3
rm -f lib/libLib.dylib; cp b3/libLib.dylib lib/
echo "same binary, v2 that keeps the old symbol: $(./app 2>&1 | head -2)"
swiftc -swift-version 5 app.swift -I b2 -L b2 -lLib -Xlinker -rpath -Xlinker @executable_path/lib2 -o app2 2>&1 | grep error
cp b2/libLib.dylib lib2/
echo "client recompiled against v2: $(./app2)"
  • v1 runs; the swapped v2 fails with "Symbol not found"; v2 with the forwarding overload runs; a recompiled client runs
  • All four runs succeed because the call site still compiles
  • The v1 client fails even against v1

Mental model. A restaurant adds an optional side dish to the order form. Customers filling the new form are fine; waiters still holding the old order forms printed for the kitchen no longer match any ticket the cook recognises.

Adding timeout: Int = 30 to fetch(id:) looks source compatible: every call site fetch(id: 7) still compiles. But a function’s binary symbol includes its full parameter list; default values are filled in by the caller’s compiler at the call site, not stored in the callee. A client built against version 1 links to the symbol fetch(id:). If the library is replaced by version 2 (a framework updated underneath an app, a binary XCFramework, a system library), that symbol no longer exists. The check builds a library with library evolution, links an app against v1, swaps in the v2 dylib without relinking, and dyld refuses to run: “Symbol not found”. Recompiling the client against v2 works, which is why this is a binary break, not a source break, and why it matters only when you ship pre-built binaries or system frameworks (apps built from source with their dependencies are always rebuilt together).

The fix is to keep the old function as a thin overload that forwards to the new one, marked @_disfavoredOverload so that new code resolves to the defaulted version while the old symbol still exists (the third run: the old binary works again and reports the default timeout). Apple uses the same approach: the old signature stays as an @available(*, deprecated) or @usableFromInline forwarding entry. Other changes that are binary breaks despite being source compatible: reordering enum cases or struct stored properties in a non-resilient type, adding stored properties to a @frozen struct, changing a function’s throws or async effects, and changing a default argument expression that was @inlinable (its old value is baked into old clients). Tools: swift-api-digester compares two versions of a module and reports source and ABI breakages; run it in CI for any binary distribution. See Q360 for deprecation and Q199 for what a module interface exposes.

Default arguments are applied by the caller, so the callee symbol changes shape.

Common traps

  • Treating “it still compiles” as “it is compatible”. Already-built clients depend on the exact symbol.
  • Dropping the old overload when adding a default. Keep a forwarding overload for at least the support window of binary clients.

They might follow up with

  • Does this matter for SwiftPM source dependencies? No, they are recompiled together; it matters for binary targets, XCFrameworks and system frameworks.
  • How do I detect it? swift-api-digester in CI, or building a client against v1 and running it against v2.

Related questions

  • 15.9.2 What do internal imports and MemberImportVisibility change, and how do you stop a dependency leaking through…
  • 15.5.2 How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus…
  • 5.4.2 What is a .swiftinterface file, and what does it show about @inlinable and @frozen?
  • 1.10.5 What does @frozen on an enum mean for you as an API author?

What do internal imports and MemberImportVisibility change, and how… →