Unsolicited Feedback on Modern Swift

I've been writing Swift code since the beginning, and I am really grateful to all the work everyone has put in to making Swift the best programming language on the planet (in my opinion). Developing SwiftClaude was my first foray into "modern" Swift with the latest in Structured Concurrency, variadic generics and learning SwiftSyntax to build some fairly sophisticated macros. Overall it has been a joyful experience and I think some of the learnings might be interesting to the folks here! So without further adieu, and in no particular order, here are some things I ran into as part of this project:

The right function color

It took me a little while to come up with a philosophy about the "right" way to use Structured Concurrency in Swift. The way I see it there are actually three different "worlds" of structured concurrency to choose from:

  1. regular async functions
  2. isolated Actor functions
  3. isolated Actor? functions

There is also a meta-strategy of "just make everything Sendable" that makes some of the issues here just go away. I went down that route initially, but ended up finding the utility of having non-Sendable types (SwiftClaude has very few Sendable types). Without marking everything sendable, you immediately run into incompatibilities between worlds (1) and (2), as discussed in this thread. Essentially, you can't call async functions that don't have an isolated argument from an isolated world.
The slightly more subtle distinction is between (2) and (3). Much example code has isolation: isolated (any Actor)? as the way to specify isolation, but I'm beginning to thing this is an anti pattern. In a nil isolation, you aren't guaranteed very much, but you are still allowed to use all of your non-Sendable types to your heart's content, though doing so will likely cause a crash very quickly. For this reason, SwiftClaude uses isolated Actor (non-optional) exclusively, since may of its data types are non-Sendable classes with mutable state.

withObservationTracking is awesome

It took me a moment to understand how it worked and how to use it, but withObservationTracking is really powerful. Initially, I had separate types for a SwiftClaude message meant to be used with SwiftUI via @Observable, and a message meant to be used with async functions. withObservationTracking allowed me to unify these types, and create API for exposing any property of an @Observable type as an AsyncSequence. Here are a few of these implementations that might be interesting for folks to reference if they are confused by withObservationTracking.
One thing I would add to withObservationTracking is to also fire onChange when the object deinitializes. I had to add a custom type to detect that case.

Reinventing Codable

While not strictly a "modern Swift" thing, I had to reinvent Codable in order to get SwiftClaude to work. The issue with Codable is that a type can do anything in init(from: Decoder), so its difficult to statically reason about the shape of the thing you are about to encode or decode. As an aside, SwiftData seems to have "solved" this in the worst possible way, using reflection to guess poorly what Codable will do.
SwiftClaude has basically its own serialization system in ClaudeToolInput. Every ToolInput type has an associated schema with a statically-known shape (variadic generics make this possible). We can generate JSON schemas from this information, which is what Anthropic's Claude API needs to implement tool use. We can then decode the statically-shaped type from Claude's response and create the final ToolInput type from the result. This is all hidden behind macro magic in SwiftClaude, but I'd love to see a future where we can statically know how a Codable type will decode.

12 Likes

Thanks for sharing your code and assessment. All these isolation clauses (and let’s not forget Sendable, with its tendency to spread like a wildfire) are quite a mouthful of boilerplate.

Thanks for shaing your experiences! I agree, strongly, that trying to make everything Sendable is a strategy that never works out.

I did want to point out though that this pattern is safe. It is not possible to get into a situation where you have no isolation but later on require it. The compiler will not permit this unless, of course, you use unsafe opt outs.

However, avoiding (any Actor)? means your types will no longer be compatible with non-ioslated uses, if any make sense. And worse, you won't able to make use of #isolation, which I find incredibly useful.

5 Likes

I'm not completely clear on what the intended behavior is for the following if isolation is nil?

final class NonSendable {
  
  func foo(isolation: isolated Actor? = #isolation) async {
    Task {
      _ = isolation
      try await Task.sleep(for: .seconds(1))
      value += 1
    }
  }
  
  var value: Int = 0
}

1 Like

The compiler should diagnose captures of non-Sendable parameters or other values not in a disconnected region if the actor value is optional. If that doesn't happen, it's a compiler bug.

4 Likes
2 Likes

For what it's worth, I have also run into crashes in places where isolation is nil. I'll see if I can manage to get a simple reproduction together, unless that's intentional? If it is... I really wish it wasn't. :sweat_smile:

2 Likes

No, it's not intentional. It sounds like a data-race safety hole, which is a compiler bug.

8 Likes