Thanks for chiming in, let us analyze the API proposal:
The write-side looks okey here and it's an interesting idea but i feel it breaks down a bit when we look at it holistically.
I'm totally open to other shapes of the API by the way if people have better ideas β it would help a lot if when pitching alternative shapes we consider all "sides" of the API: declaration, read side, write side (including multiple values), otherwise it's easy to make one of the sides look great at the expense of other ones.
Write side: Yes, you're right the write side ends up equivalent to what the value handles express. It's the same amount of boilerplate and has the same challenges around "why not just Key.bound(to: ...) { ... }
?" which I'll answer pre-emptively: because I'm trying to keep all "creates and modifies tasks" Task APIs on the Task namespace, so they are simple to discover. Maybe that's not so important though.
Read side: With your proposel to just do a key type, the read sides becomes a bit ugly:
let x = await Task.local(Key.self)
Which is the shape that Swift UI and Baggage from Distributed Tracing specifically avoided, after many months of bikeshedding
What those libraries then end up doing is asking developers to make the keys private, and control access via a computed property like this:
private enum ThingKey: ... {}
extension ... {
var thing: Thing {
get { ... }
set { ... }
}
}
which is how one arrives at those \.thing
APIs eventually.
The issue is that... we can't express what we need using such API shape!
... because access to a task local value must only be performed from within a task, i.e. the functions for reading and binding it must be async functions. Swift does not allow for async accessors. And even if it did (maybe we'll allow async getters), then the "set" operation is also wrong, since we must introduce new scopes when we bind values -- we cannot just "set" them (it'd break the model explained in Detailed design).
I also think when used with a more realistic type and key it becomes tricky how we'd namespace those things. Let's try to stick to RequestID
as that's a pretty simple concept but it also includes it's own type already (say we have some RequestID
type), then the example above becomes:
struct RequestID { ... }
enum RequestIDKey: TaskLocalKey {
static var defaultValue: RequestID? { nil }
}
since we had to disambiguate the actual type from the key we use to refer to it... So I guess we'd need enum TaskLocalValues {}
onto which people can put the keys β that's not too bad, and somewhat SwiftUI consistent.
I'm not sure we like the "ugly read" or if we should go all-in on await TaskLocalValues.RequestID.get()
. That could work.
Looking forward to more feedback to get a feel what API people would be comfortable with. I'm really not that much married to the API shapes here, as long as the internal design keeps the guarantees as outlines in the proposal.
I'll play around with this some more, and would welcome more complete examples how people feel such shape could work out well in practice.
I don't think property wrappers are the right way to approach this feature.
It is not really right to think about them being "stored" anywhere else other than "in the task." I tend to think about SwiftUI's environment as "top/down" while Task Locals are more like "from beginning to end of task" and that's a small difference on paper, but huge one in how those values are used.
To clarify though: A property wrapper implies there has to be a property defined for it somewhere, meaning, there is some storage in some type allocated for it -- but that storage is always a lie. It cannot contain the actual task local value, because that depends on who (what task) calls it. And even if we said the property wrapper exposes an async get()
only, we cannot prevent people from looking at the $storage property -- which would always contain nonsense.