You can do this without any hacks.
If your goal is simply to read all of stdin into a String, you don't need any of the unsafe* functions.
IO is a Monad, and a Monad is an Applicative Functor. A Functor is defined by the function fmap, whose signature is:
fmap :: Functor f => (a -> b) -> f a -> f b
that satisfies these two laws:
fmap id = id
fmap (f . g) = fmap f . fmap g
Effectively, fmap applies a function to wrapped values.
Given a specific character 'c', what is the type of fmap ('c':)? We can write the two types down, then unify them:
fmap :: Functor f => (a -> b ) -> f a -> f b
('c':) :: [Char] -> [Char]
fmap ('c':) :: Functor f => ([Char] -> [Char]) -> f [Char] -> f [Char]
Recalling that IO is a functor, if we want to define myGetContents :: IO [Char], it seems reasonable to use this:
myGetContents :: IO [Char]
myGetContents = do
x <- getChar
fmap (x:) myGetContents
This is close, but not quite equivalent to getContents, as this version will attempt to read past the end of the file and throw an error instead of returning a string. Just looking at it should make that clear: there is no way to return a concrete list, only an infinite cons chain. Knowing that the concrete case is "" at EOF (and using the infix syntax <$> for fmap) brings us to:
import System.IO
myGetContents :: IO [Char]
myGetContents = do
reachedEOF <- isEOF
if reachedEOF
then return []
else do
x <- getChar
(x:) <$> myGetContents
The Applicative class affords a (slight) simplification.
Recall that IO is an Applicative Functor, not just any old Functor. There are "Applicative Laws" associated with this typeclass much like the "Functor Laws", but we'll look specifically at <*>:
<*> :: Applicative f => f (a -> b) -> f a -> f b
This is almost identical to fmap (a.k.a. <$>), except that the function to apply is also wrapped. We can then avoid the bind in our else clause by using the Applicative style:
import System.IO
myGetContents :: IO String
myGetContents = do
reachedEOF <- isEOF
if reachedEOF
then return []
else (:) <$> getChar <*> myGetContents
One modification is necessary if the input may be infinite.
Remember when I said that you don't need the unsafe* functions if you just want to read all of stdin into a String? Well, if you just want some of the input, you do. If your input might be infinitely long, you definitely do. The final program differs in one import and a single word:
import System.IO
import System.IO.Unsafe
myGetContents :: IO [Char]
myGetContents = do
reachedEOF <- isEOF
if reachedEOF
then return []
else (:) <$> getChar <*> unsafeInterleaveIO myGetContents
The defining function of lazy IO is unsafeInterleaveIO (from System.IO.Unsafe). This delays the computation of the IO action until it is demanded.