Building a Shell in Haskell: Guards and Command Dispatch
A small shell loop uses ordered guards to distinguish exit, echo, type, and unknown commands without nested conditional branches.
A shell repeats one operation: print a prompt, read a command, decide what it means, then either stop or return to the prompt. The first CodeCrafters stages implement only built-in commands, so command dispatch is the central piece.
prompt → read line → identify command → print result → prompt
exit is the one path that does not return to the next prompt.
The Shell Loop
main is an IO action because the prompt and command line interact with the terminal. putStr writes the prompt without a newline. hFlush stdout forces it to appear before getLine waits for input.
main :: IO ()
main = do
putStr "$ "
hFlush stdout
command <- getLine
handleCommand command
getLine produces an IO String; <- binds the resulting String to command inside the do block. The loop itself is formed by calling main again after a command has been handled.
From Nested if to Guards
The first version used nested if expressions:
if command == "exit"
then pure ()
else if "echo " `isPrefixOf` command
then do
putStrLn (drop 5 command)
main
else do
putStrLn $ command ++ ": command not found"
main
This is valid Haskell. Each else contains the remaining cases, so the indentation grows as more commands are added.
Guards put those cases alongside one another instead:
handleCommand command
| command == "exit" = pure ()
| "echo " `isPrefixOf` command = ...
| "type " `isPrefixOf` command = ...
| otherwise = ...
Guards are tried from top to bottom. The first condition that evaluates to True supplies the result. otherwise is the final catch-all; it is simply a name for True.
The behaviour is still conditional branching. The difference is structural: each command case is a peer instead of a branch nested inside the previous case. That fits command dispatch because the program is choosing one handler from an ordered set of handlers.
Built-in Commands
The shell knows its built-ins before it reads any command.
builtInCmds :: [String]
builtInCmds = ["echo", "exit", "type"]
The list is the single source of truth for the type command. Adding a new built-in later means adding a handler and adding its name to this list.
isPrefixOf checks whether one list begins with another. Since String is a list of Char, it checks command prefixes directly.
"echo " `isPrefixOf` "echo hello"
-- True
"echo " `isPrefixOf` "exit"
-- False
The space after echo and type matters. "type" alone would also match input such as typewriter; "type " identifies the command followed by its argument.
Guard-Based Command Handler
handleCommand :: String -> IO ()
handleCommand command
| command == "exit" = pure ()
| "echo " `isPrefixOf` command = do
putStrLn (drop 5 command)
main
| "type " `isPrefixOf` command = do
let target = drop 5 command
if target `elem` builtInCmds
then putStrLn $ target ++ " is a shell builtin"
else putStrLn $ target ++ ": not found"
main
| otherwise = do
putStrLn $ command ++ ": command not found"
main
drop 5 removes the five characters in echo or type . For echo hello, the remaining string is hello. For type echo, the remaining string is echo.
elem checks whether the target name belongs to builtInCmds. That keeps the test separate from the output format:
type echo → echo is a shell builtin
type cd → cd: not found
pure () ends the exit branch with an IO action that has no observable effect. It does not call main, so the program finishes.
One Limitation of Prefix Parsing
isPrefixOf and drop 5 are enough for these first commands, but they assume exactly one space after the command and treat the remainder as one raw string. A real shell needs to recognise whitespace, quoted arguments, escapes, environment variables, and executable paths.
words "type echo"
-- ["type", "echo"]
words is a useful next step for simple space-separated commands, but it does not understand quotes. Shell parsing becomes its own subsystem once commands such as echo "hello world" and external programs are introduced.
The current structure leaves room for that change. main owns the terminal loop; handleCommand owns dispatch; a future parser can turn the raw String into a command name and argument list before dispatch begins.