Now again, I don't know about Effect<.,.>, that's very library specific. So my example will be based on "pure" reducers found in other Redux-like libraries.
protocol Reducer {
associatedtype State
associatedtype Environment
func run<Action>(state: inout State, action: Action, environment: Environment)
}
protocol BaseReducer : Reducer {
associatedtype Action
func run (state: inout State, action: Action, environment: Environment)
}
extension BaseReducer {
func run<T>(state: inout State, action: T, environment: Environment) {
guard let action = action as? Action else {return}
run(state: &state, action: action, environment: environment)
}
}
struct ComposedReducer<R1 : Reducer, R2 : Reducer> : Reducer where
R1.State == R2.State, R1.Environment == R2.Environment {
let r1 : R1
let r2 : R2
func run<Action>(state: inout R1.State, action: Action, environment: R1.Environment) {
r1.run(state: &state, action: action, environment: environment)
r2.run(state: &state, action: action, environment: environment)
}
}
The individual reducers and the composed reducers now have all the types available in advance. So when you call
topLevelReducer.run(state: &appState, action: "Hello, World!", environment: appEnvironment)
the compiler will generate a new function via generic specialization. Say, your reducer hierarchy looks like this:
struct Reducer1 : BaseReducer {
func run(state: inout AppState, action: Int, environment: AppEnvironment){...}
}
struct Reducer2 : BaseReducer {
func run(state: inout AppState, action: [Double], environment: AppEnvironment){...}
}
let topLevelReducer = CombinedReducer(r1: Reducer1(), r2: Reducer2())
Then, the function generated for you by the compiler in the case action: "Hello, World!" will look like this (I'm paraphrasing):
func run(state: inout AppState, action: String, environment: Environment) {
if let action = action as? Int { //will never succeed
r1.run(state: &state, action: action, environment: Environment)
}
if let action = action as? [Double] { //will never succeed
r2.run(state: &state, action: action, environment: Environment)
}
}
Since the compiler knows in advance that the downcasts won't succeed, it can (and in optimized builds will) eliminate those calls - and actually the entire reducer call, as it is empty.
Edit: That's just an example how protocol based approaches can lead to improved performance and that you can achieve static dispatch (which sometimes is desirable) - especially in case of highly unbalanced reducer trees where the action you want to trigger is deeply nested and all other reducers don't respond to the request and just do unneccessary checking if they should react.
I'm aware that this introduces other conceptual problems, e.g. you may accidentally call reducers with actions that have absolutely no effect. It's cool that those calls will be completely optimized away, but one may ask if it is legit to even make such calls.
Edit Edit: The extreme case that no reducer in your hierarchy responds to an action can be mitigated by marking valid actions in your app with a marker protocol AppAction and wrapping your store's dispatch<Action> function into a constrained dispatchSafe<A : AppAction> function. You'd still lose out on "action hierarchies" that correspond to your reducer hierarchies (unless you have different marker protocols for different parts of your reducer hierarchy and write some dispatchSafe<A : Whatever> boilerplate).