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
async
functions isolated Actor
functionsisolated 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.