I think a function with 20+ effects is probably a “code smell”, regardless how you pass the handlers around. It may mean that:
-
The effects are too fine-grained.
For example, two separate effects
Stdin
andStdout
instead ofConsole
(which combines both) may not buy you much unless you actually need the flexibility. -
Effects deep down in a call stack are propagated all the way to the high-level functions.
In large code bases, propagating a type change transitively to all of the use sites can cause a massive amount of churn and headache.
Solutions may be:
-
Combining multiple effects into larger ones.
In the example above, structured concurrency, concurrency, and logging may be combined in a single effect if a lot of code use them in combination.
-
Modeling entire parts of the program as effects.
If I have an message loop that receives RPC messages and calls handlers (maybe provided as callbacks), instead of propagating all of the effects of all of the message handlers to the type signature of the message loop, I may implement an effect with message handling operations. Handlers/interpreters would then use other effects in the use site of the loop, but the message loop function itself would not have to be updated with effects of message handling code as they are updated.
(This effectively separates the code for the message loop and handling the messages, which may not always be desirable.)
-
A type-synonym-like approach for naming a collection of effects, and using that name in the call sites (transitively) might make this more manageable. If I start using a new effect deep down in a call stack, I can update the type synonym, and if all the callers also use I don’t have to change them, until the code that needs to handle the new effect.
(I suspect this is probably not possible with the type system features of GHC today, but just an idea, perhaps for another library or language.)