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:
- regular
asyncfunctions isolated Actorfunctionsisolated 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.