Simplifying if statement in a StateT monad transformer
I don't like your pop and push. They're shaped specially for StateT, but leave off the wrapper. I'd suggest, instead, something like this:

pop :: Monad m
    =&gt; StateT [a] m (Maybe a)
pop = do
  s &lt;- get
  case s of
    x : s' -&gt; Just x &lt;$ put s'
    [] -&gt; pure Nothing

push :: Monad m
     =&gt; a -&gt; StateT [a] ()
push a = modify (a :)


Of course, you can implement those using state, but I think this way is clearer. Once you've gone that far, you might consider using Control.Monad.State.Strict (from mtl) and changing the type signatures:

pop :: MonadState [a] m
    =&gt; m (Maybe a)
push :: MonadState [a] m
     =&gt; a -&gt; m a


Side note: Control.Monad.Trans.State implements lazy state transformers, which are rather weird beasts you usually don't need. Unless you have a special reason, reach for Control.Monad.Trans.State.Strict or Control.Monad.State.Strict instead.

Moving on with the mtl approach: StateT s (MaybeT m) a is really

s -&gt; MaybeT m (a, s)


or

s -&gt; m (Maybe (a, s))


What about MaybeT (StateT s m) a? That's

StateT s m (Maybe a)


or

s -&gt; m (Maybe a, s)


Not quite the same, but pretty similar! Their monadic operations are essentially the same. The big difference is that in the latter case you get the last state whether or not the operation stopped short. I think you may end up happier that way, with

mainLoop :: MaybeT (StateT Stack IO) x
mainLoop = do
  input &lt;- liftIO getLine
  if null input
    then do
      -- No pattern match!
      x &lt;- MaybeT pop
      liftIO . putStrLn $ x
    else push input
  mainLoop


Extra side note: we can make mainLoop polymorphic in its return value, because it never returns.



An alternative:

pop :: (MonadState [a] m, MonadFail m)
    =&gt; m a
pop = do
  s &lt;- get
  case s of
    x : s' -&gt; x &lt;$ put s'
    [] -&gt; fail "Empty stack"


Now you can use the same mainLoop code whichever order you choose to stack the transformers:

mainLoop :: ( MonadState Stack m
            , MonadIO m
            , MonadFail m )
         =&gt; m x
mainLoop = do
  input &lt;- liftIO getLine
  if null input
    then do
      x &lt;- pop
      liftIO . putStrLn $ x
    else push input
  mainLoop


In my opinion, though, this is less clear than the other options, and probably doesn't add much useful flexibility.