Haskell command line todo list
The general idea is to use library code, particularly to eliminate explicit recursion, and inline things used only once.

MaybeT abstracts computations that can fail and abort at some point, and allows you to bind into pattern matches that fail the computation if they don't match.

desperately abstracts retrying them until they work, using MaybeTs Alternative instance.

StateT abstracts computations that carry around a piece of state to read and write to.

forever defeats the need for loop to manually loop.

Contrary to that comment, abstraction is kinda Haskell's thing.

import System.Exit (exitSuccess)
import Control.Monad.State
import Control.Monad.Trans.Maybe
import Safe (fromJustNote)

type TodoList = [String]

main :: IO ()
main = do
    putStrLn "TODO-LIST\n"
    (`evalStateT` []) $ forever $ do
        say "----------------------------"
        say "You have to do these things:"
        say ∘ unlines ∘ zipWith (\i e -&gt; show i ++ ": " ++ show e) ([0..] :: [Integer]) =&lt;&lt; get
        input &lt;- desperately $ do
            say "Select an action"
            say ∘ unlines $ map (\(char, desc, _) -&gt; char : ") " ++ desc) options
            [c] &lt;- hear
            return c
        case find (\(c, _, _) -&gt; c == input) options of
            Just (_, _, action) -&gt; action
            _ -&gt; say "action not found"

desperately :: Monad m =&gt; MaybeT m a -&gt; m a
desperately = fmap (fromJustNote "desperately") . runMaybeT . asum . repeat

say = liftIO . putStrLn
hear = liftIO getLine


To bring main's last lines closer in method with the rest of your code, you could do Just (_, _, action) &lt;- find (\(c, _, _) -&gt; c == input) options and put another desperately to the right of forever. (Or runMaybeT, because the forever repeats everything anyway, but that's kinda incidental.)

When you implement a transformation of a simple partial algorithm into a safe one, chances are safe's already got something, here splitAtExactMay.

(,,) in its prefix form allows me to not close each option with a multi-line closing bracket, and shuddup indentation blocks split code into units just as well as names do, and then you don't need to choose names for everything. Each option even already lists a description of what it does!

import Safe.Exact (splitAtExactMay) -- goes at the top of the file, but relevant here

options :: [(Char, String, StateT TodoList IO ())]
options =
    [ (,,) 'c' "create new item" $ do
        say "enter a description of the new item:"
        hear &gt;&gt;= modify . (:)
    , (,,) 'd' "delete item" $ desperately $ do
        say "enter the number of the item you want to delete"
        Just n &lt;- readMaybe &lt;$&gt; hear
        Just (before, _ : after) &lt;- gets $ splitAtExactMay n
        put $ before ++ after
    , (,,) 'x' "delete everything" $ put []
    , (,,) 'l' "load file" $ desperately $ do
        say "enter the file you want to load"
        line &lt;- hear
        Right file &lt;- liftIO $ tryIOError $ openFile line ReadMode
        liftIO $ hSetEncoding file utf8
        Right content &lt;- liftIO $ tryIOError $ hGetContents file
        modify (++ lines content)
    , (,,) 's' "save to file" $ desperately $ do
        say "enter the filename to save your todolist. The file will be overwritten."
        line &lt;- hear
        Right file &lt;- liftIO $ tryIOError $ openFile line WriteMode
        liftIO $ hSetEncoding file utf8
        result &lt;- liftIO . tryIOError . hPutStr file . unlines =&lt;&lt; get
        case result of
            Left _  -&gt; say "couldn't write to file"
            Right _ -&gt; liftIO $ hClose file
    , (,,) 'q' "quit" exitSuccess
    ]