IO is infectious. How can we make it less infectious. See for instance https://effect.readthedocs.org/en/latest/intro.html which boils down to a form of DI - the performing of the Effect can be precisely controlled, but you still have had to write all three functions as though they might fail in arbitrary ways, rather than writing just one of them that way.
There has to be a better way.
I need to learn more about how folk test IO code in Haskell. Functional tests are unsatisfying.
The Python effect library is pretty interesting, but its not as close a mapping to IO as I once thought.
Effect models an entire program as two things. A parameterisable interpreter(1) which interprets a DSL(2) that describes how to link together [relatively] pure code that depends on IO of some form. I say relatively pure code because its Python and you could do whatever you want. But the intent is that you'd have one type to represent writes, another to represent reads, and something like a WSGI app would become a single somewhat static data structure you can pass off to the interpreter at the start of things, and the interpreter can evaluate when to read, when to write, and when to throw. So its kindof cool, but this is fundamentally different to IO <type>. The DSL here is effectively a tree AFAICT - it's not particularly suited to e.g. describing things recursively. Types in the DSL related to the IO to perform, not the datatype returned. The DSL itself encodes the assumption of (approximately) the Either monad everywhere - which is why a tree is formed: A single Effect has function references for both Left and Right (failure and success), and they get recursively evaluated during interpretation. There are two specifically nice things here. Firstly, one can potentially run tests against the structure of the tree generated: though its able to be dynamic via the do decorator, or returning Effect objects in response to IO, so its not fully static. The other thing is the decoupling of IO and computation, so its possible to take one set of code and run it with threads, with Twisted, with asyncio, solely by writing replacement performers: the things bound to actual IO. That makes test doubles quite a bit easier to write and test.
Contrast with Haskell itself, where what happens is that the IO monad acts as a decorator turning all your functions that (say for [readline[(https://hackage.haskell.org/package/readline-188.8.131.52/docs/System-Console-Readline.html#v:readline)) take a string and return a string, and wraps them up so that they take and return a RealWorld parameter as well - but this gets optimised out by the compiler, and the coolness of Monads is they also get optimised out of the source code :). But its this threading of a hypothetical data parameter through all the functions (including recursively defined ones) that gives you ordering, and lets you write 'imperative' code in a purely functional language. You never see RealWorld in function definitions because the the type of the function is input1, ..., inputN -> IO (output), but return binds it in via State.
If you look closely, you'll see that IO String actually holds a function parameterised by String.
IO (State# RealWorld -> (State# RealWorld, String)) - the extra input T and output T in (T,a) are supplied entirely by the monad instance. So all the machinery is doing is letting you say 'this into that into other thing' without having to manually express data dependencies. Something that Python doesn't need at all (because it isn't lazy, things are always evaluated.
There doesn't seem to be any mechanism in the IO Monad for switching in a wholly different implementation of the functions one calls: yes they're first class objects, but you need to arrange to pass the right ones in: readline is going to do readline, not some synthetic thing that aids testing.