@Access
macro is designed to simplify granular access level control for your Swift types.
@Access(emit: .fileprivate)
enum Action {
case didStart
case didFinish
}
// generates ⬇️
public struct ActionAccessor {
let value: Action
fileprivate init(_ value: Action) {
self.value = value
}
fileprivate static let didStart = Self(.didStart)
fileprivate static let didFinish = Self(.didFinish)
}
There are some discussions about action boundaries in The Composable Architecture:
- TCA Action Boundaries
- Thoughts on "Action Boundaries" to keep Actions organized and their intent explicit
The idea is that we want to avoid making a flat Action
enum containing all actions, and instead opt for a more nested structure:
public enum Action {
public enum Public: Equatable {
case load(URL)
}
public enum Delegate: Equatable {
case didFinishLoading
}
public enum Internal {
case progressChanged(Double)
case loadingFinished
}
case `public`(Public)
case delegate(Delegate)
case `internal`(Internal)
}
That way we can write exhaustive switches over comprehensible subsets of actions, instead of falling back to default
.
The problem with this approach that I see is that out of the box it doesn't prevent misuse, any action can still be emitted and read anywhere, it's just a bit harder to do accidentally. Better situation can be achieved with custom lint rules, but I believe the type system can be utilised for a better solution.
The @Access
macro creates a public struct
wrapping the annotated type and lets you specify access level for read
and emit
separately. read
affects which part of the app can read the actual value of the type (e.g. switch
over the action). emit
affects which part of the app can create an instance of the type. See previous example improved:
public enum Action {
// Public action can be created anywhere, can be read only in the file scope
@Access(read: .fileprivate)
public enum Public: Equatable {
case load(URL)
}
// Delegate action can only be created in the file scope, but can be
// accessed anywhere
@Access(emit: .fileprivate)
public enum Delegate: Equatable {
case didFinishLoading
}
// Fileprivate action inherits `fileprivate` modifier for both reading and emitting,
// forbidding both outside of the file scope, but still letting the action
// be a part of a public enum
@Access
fileprivate enum Fileprivate {
case progressChanged(Double)
case loadingFinished
}
case `public`(PublicAccessor)
case delegate(DelegateAccessor)
case `fileprivate`(FileprivateAccessor)
}
By placing the action declaration in the same file as the TCA Reducer
we can limit Fileprivate
action to be only visible in that file,
while allowing parent reducers read Delegate
action and emit Public
action, forbidding the rest.
The downside of the approach is that the whole action can't be switched over using single switch
and a separate switch
statements are required over each action.value
,
but that can be benefitial for ensuring less catch all default
statements and also can be mitigated by using a generated is
function for Equatable
types.
See AccessMacroClient and AccessTests for more readable examples.
- Generate a wrapper with separate access levels to
let value
andinit(value)
- Generate properties to instantiate simple cases of a wrapped enum
case didStart
// of a wrapped type
// yields
fileprivate static let didStart = Self(.didStart)
// on a wrapper, keeping the action creation syntax intact
- Generate functions instantiate cases of a wrapped enum with associated values
case didStart(at: Date)
// of a wrapped type
// yields
fileprivate static func didStart(at: Date) -> Self {
return Self(.didStart(at: at))
}
// on a wrapper, keeping the action creation syntax intact
- Derive conformances to
Equatable
andHashable
- Generate an
is
function to use in conjunction withwhere
inside a switch - Derive generics
- Allow custom property name instead of
value
- Generate other type members that delegate to a wrapped type