Simple guessing game in Haskell
Great job on writing readUntilValid as a separate function instead of inlining it in playWith. That being said, we can go a little bit further with splitting the functionality of your code.

At the moment, your main contains five distinct steps:


greet the user with the rules
choose a random number
start the game
ask whether the user wants to play again
restart


The random number is part of the game, so it's a little bit strange to generate it in main. Your function playWith already suggests a name for a another function that takes care of that step:

play :: IO ()
play = randomRIO (1, 100) &gt;&gt;= playWith


randomRIO takes a range, so we end up with the correct numbers. Your program choosed numbers between 0-99, by the way.

However, now we can also get the rules in there:

import Control.Monad (when)
import System.Random (randomRIO)
import Text.Read (readMaybe)

play :: IO ()
play = do
  putStrLn "I am guessing a value between 1 to 100."
  randomRIO (1, 100) &gt;&gt;= playWith

main :: IO ()
main = do
  play
  response &lt;- prompt "Play again? (Y/N)"
  when (response == "Y") main

prompt :: String -&gt; IO String
prompt xs = putStr (xs ++ " ") &gt;&gt; getLine


The prompt is just a little helper for questions to the user. What did we gain from splitting main? Well, main does not have to care for the game at all. We can replace play by any other game, e.g. playTicTacToe, and we don't have to change our main at all. So try to isolate your code a little bit.

Next we have playWith. There's a bug in there, you use n twice, once as (correct) answer, once as the number of turns the user has. Therefore, they have to guess 5, 4, and so on. The original random number wasn't used at all. Let's fix that first:

playWith :: Int -&gt; IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You loose!"
  go n = do
    putStr "Enter your guess: "
    guess &lt;- readUntilValid 
    if answer == guess then
      putStrLn "You win!"
    else
      if answer &lt; guess then
          putStrLn "Too large!" &gt;&gt; go (n-1)
      else
          putStrLn "Too small!" &gt;&gt; go (n-1)


Note that GHC will warn you about shadowing if you use -Wall. But we can still do better. If you're going to compare two values and want to act on more than a single outcome, use compare instead of two checks (here ==  and &lt;):

playWith :: Int -&gt; IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You lost!"
  go n = do
    putStr "Enter your guess: "
    guess &lt;- readUntilValid

    case guess `compare` answer of
      EQ -&gt; putStrLn "You win!"
      LT -&gt; putStrLn "Too small!" &gt;&gt; go (n-1)
      GT -&gt; putStrLn "Too large!" &gt;&gt; go (n-1)


And now back to readUntilValid As I said, it's completely fine, however the additional type signature isn't necessary, as the compiler can infer readMaybe's type in that context. We end up with:

readUntilValid :: IO Int
readUntilValid = do
  s &lt;- getLine
  case readMaybe s of
    Nothing -&gt; putStr "Please enter a valid guess:" &gt;&gt; readUntilValid
    Just x  -&gt; return x


Note that you can even write it as

readUntilValid :: Read a =&gt; IO a


but that's an aside. We end up with

import Control.Monad (when)

main :: IO ()
main = do
  play
  response &lt;- prompt "Play again? (Y/N)"
  when (response == "Y") main

play :: IO ()
play = do
  putStrLn "I am guessing a value between 1 to 100."
  randomRIO (1, 100) &gt;&gt;= playWith

playWith :: Int -&gt; IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You lost!"
  go n = do
    guess &lt;- askNumber

    case guess `compare` answer of
      EQ -&gt; putStrLn "You win!"
      LT -&gt; putStrLn "Too small!" &gt;&gt; go (n-1)
      GT -&gt; putStrLn "Too large!" &gt;&gt; go (n-1)

prompt :: String -&gt; IO String
prompt xs = putStr (xs ++ " ") &gt;&gt; getLine

askNumber :: IO Int
askNumber = do
  response &lt;- prompt "Please enter your guess (1-100): "
  case readMaybe response of
    Nothing -&gt; putStrLn "Not a valid guess, try again." &gt;&gt; askNumber
    Just x  -&gt; return x


I've renamed readUntilValid and also let it ask the number, so that again everything that is concerned with getting input from the user resides in a single function.

All in all, well done. Don't be afraid to split your functions, make sure to enable compiler warnings, and use hlint (it suggested when from Control.Monad, by the way).

Exercises


at the moment, the user will only replay the game if they enter Y. Also accept other inputs, such as "yes", "YES" and "y", and ask again if they don't say "yes" or "no" in a clear way
the number of guesses is currently fixed in playWith. Make it variable
the range is currently fixed (1-100). Make it variable
have the user choose a difficulty. The difficulty should determine the original range as well as the number of guesses. Keep the lower difficulty levels fair, e.g.

 difficulty   | range      | guesses
--------------+------------+-------------------------------
 trivial      | 1-10       | 10
 easy         | 1-100      | 10
 medium       | 1-100      | 7
 hard         | 1-1000     | 10 -- still fair
 harder       | 1-10000    | 13 -- beginning to get unfair
 hardest      | 1-1000000  | 17 -- missing three guesses
 lucky        | 1-20       | 1

the user doesn't know how many guesses they have. You should probably tell them at some point, especially with difficulty levels
instead of (), playWith could return data GameEnd = Won | Lost, but that's just for style and not necessary. You could use that information for statistics and so on, but that's an overkill


Have fun.