Beginner's Implementation of Guess The Word
Overall the code looks really nice. I like the separation with a separate Difficulty type instead of doing the parsing directly in fetchWords.
Bugs
I have found three bugs:
Crash on invalid user input
If a user enters a number that is larger than the number of characters in the string, the program will crash. This is because you use the partial1 function (!!) without verifying its precondition that for xs !! i, i &gt;= 0 and i &lt; length xs. This could also be solved if you used a non-partial version of the function which returns a Maybe value so you can display a better error to the user and ask them to retry.
Random crash every 3:rd execution
There is also an off-by-one error in generateSecret, since randomRs generates an inclusive range and length xs is not a valid index for xs. This will cause the program to crash randomly on average once every length (fetchWords difficulty) + 1 times you play.
Only one random number
Speaking of randomness: the randomNumber function calls getStdGen, which
doesn't update the RNG state, it just fetches it. This means that all random numbers will be the same. To make it update the state, you can either use randomRIO which will update the global RNG state automatically, or use newStdGen which will split the RNG state to allow you to generate random numbers from pure code. In this code you don't notice it though, since you only generate a single number.
The randomNumber function can be fixed by simplifying it to
randomNumber :: Int -&gt; Int -&gt; IO Int
randomNumber begin end = randomRIO (begin, end)

Idiomatic code
Partial functions
You use partial functions in a couple of places. Many of them can be avoided by using the principle &quot;Parse, don't validate&quot;. In this case, all the &quot;isX&quot; functions can be replaced with getX functions that try to parse the data and Maybe returns a parsed value. The createDifficulty function is already such a function. This guarantees that the validation actually matches the code that uses it.
Here's an example of how it could be done. I have only included the relevant functions here. The index is validated, but there is no easy way to tell which list it is a valid index for unless you use some advanced trickery2.
import Text.Read (readMaybe)

newtype ValidIndex = ValidIndex Int

createDifficulty :: String -&gt; Maybe Difficulty
createDifficulty s = lookup s [
    (&quot;e&quot;, Easy),
    (&quot;m&quot;, Medium),
    (&quot;h&quot;, Hard),
    (&quot;&quot;, Easy)
    ]

getInt :: String -&gt; Maybe Int
getInt = readMaybe

isValidIndex :: [a] -&gt; Int -&gt; Bool
isValidIndex list idx = idx &gt;= 0 &amp;&amp; idx &lt; length list

getValidIndex :: [a] -&gt; String -&gt; Maybe ValidIndex
getValidIndex list str = do
  idx &lt;- getInt
  guard $ isValidIndex list idx
  return $ ValidIndex idx

getChar :: String -&gt; Maybe Char
getChar [c] | isAlpha c = Just c
getChar []  = Nothing

promptUntil :: (String -&gt; Maybe a) -&gt; String -&gt; String -&gt; IO a
promptUntil parser prompt failureMessage = do
    putStr (prompt ++ &quot;\n&gt; &quot;)
    input &lt;- getLine
    case parser input of
      Just result -&gt; return result
      Nothing -&gt; do
        putStrLn $ input ++ failureMessage
        promptUntil parser prompt failure

loop :: String -&gt; String -&gt; IO ()
loop secret guess = do

    putStrLn guess

    pos &lt;- promptUntil (getValidIndex secret) &quot;Enter position:&quot; &quot; is not a valid index.&quot;
    c &lt;- promptUntil getChar &quot;Enter character:&quot; &quot; is not a character.&quot;

    let result = compareSecret secret guess pos c
    if result /= secret then
        loop secret result
    else
        return ()

main = do
    putStrLn &quot;\n Welcome to Guess The Word!\n&quot;

    difficulty &lt;- promptUntil createDifficulty &quot;Enter difficulty level [e/m/h] or leave empty for easy difficulty: &quot;
        &quot; is not a valid difficulty.&quot;

    putStrLn $ show difficulty ++ &quot; difficulty selected.&quot;

    secret &lt;- generateSecret difficulty

    loop secret $ replicate (length secret) '-'

    putStrLn secret
    putStrLn &quot;You win!&quot;

-- These functions are still partial, but we have checked their preconditions.
replaceChar :: ValidIndex -&gt; String -&gt; Char -&gt; String
replaceChar (ValidIndex n) s c = pre ++ [c] ++ tail post
    where (pre, post) = splitAt n s

compareSecret :: String -&gt; String -&gt; ValidIndex -&gt; Char -&gt; String
compareSecret secret guess pos@(ValidIndex i) c =
    if secret !! i == c then
        replaceChar pos guess c
    else
        guess

Separating business logic from generic combinators
There are some cases where you can separate the code that is specific
to your application from generic code that could be used anywhere.
For example, the generateSecret function can be split into a pickOne function:
pickOne :: [a] -&gt; IO a
pickOne [] = error &quot;pickOne: Empty list&quot;
pickOne xs = do
    num &lt;- randomRIO (0, length xs - 1)
    return $ xs !! num

generateSecret :: Difficulty -&gt; IO (String)
generateSecret = pickOne . fetchWords


As you can see, the function is still partial, but since the word list is hard coded, we can probably assume that there is at least one word for each difficulty.
Explicit import lists
The recommended way of handling imports is to either use a qualified import like this:
import qualified Data.Char as Char

example = Char.isDigit '2'

or use an explicit import list like this
import Data.Char (isDigit)

example = isDigit '2'

This makes it easier for readers to see where functions come from.
Performance
Since all strings and lists are really short in this example, performance will not really be an issue. However, if the strings or lists were a lot longer, there are a few things to note. All functions that work with an index into a list are O(n) (instead of the O(1) they would be if we were working with a mutable array).
Haskell has support for mutable vectors, but then you lose the purity that is so nice about Haskell. If you want to keep purity, you can use a Data.Map, which has O(n*log n) complexity, but at the cost of a O(n*log n) memory cost. You could also use Data.Text, which still has O(n) complexity, but has a much more compact representation and is faster overall.
Anyways, as I said, this is not really relevant for this program since n (the length of the lists/strings) is really small and will probably never be large.


A partial function is any function that can crash
For example using Liquid Haskell or by using some fancy type-level trickery where the length of the list is in its type