First of all, main not a function. This is really just a regular value, and its type is IO () . A type can be read as: An action that, when executed, produces a value of type () .
Now the runtime system acts as an interpreter that performs the actions described by you. Take your program as an example:
main = forever (putStrLn "Hello world!")
Please note that I performed the conversion. This is true because Haskell is a referentially transparent language. The runtime system resolves the forever and finds this:
main = putStrLn "Hello world!" >> MORE1
He does not yet know what MORE1 , but now he knows that he has a composition with one known action that is performed. After its execution, it resolves the second action, MORE1 and finds:
MORE1 = putStrLn "Hello world!" >> MORE2
Again he performs the first action in this composition, and then continues to resolve.
Of course, this is a high-level description. Actual code is not an interpreter. But this is a way to show how the Haskell program is executed. Take another example:
main = forever (getLine >>= putStrLn)
RTS sees this:
main = forever MORE1 << resolving forever >> MORE1 = getLine >>= MORE2 << executing getLine >> MORE2 result = putStrLn result >> MORE1 << executing putStrLn result (where 'result' is the line read) and starting over >>
Understanding this, you understand how an IO String not a “side effect string”, but rather a description of the action that would create the string. You also understand why laziness is critical to the operation of the Haskell I / O system.