Hell: Shell scripting Haskell dialect

4 min read Original article ↗

Hell is a shell scripting language that is a tiny dialect of Haskell that I wrote for my own shell scripting purposes. As of February, I’m using Hell to generate this blog, instead of Hakyll.1

Update: As of 3rd Oct 2024, I’m using it on various large (2k line) scripts at work in combination with Terraform and various APIs.

#!/usr/bin/env hell
main = do
  Text.putStrLn "Please enter your name and hit ENTER:"
  name <- Text.getLine
  Text.putStrLn "Thanks, your name is: "
  Text.putStrLn name

My 2024 New Year’s Resolution is to write more shell scripts in the name of automation. I’ve always avoided this because of the downsides of bash. And other problems.

Bash, zsh, fish, etc. have problems:

  • They’re incomprehensible gobbledegook.
  • They use quotation (x=$(ls -1) ..) which makes it easy to make mistakes.
  • They lean far too heavily on sub processes to do basic things.
  • Therefore things like equality, arithmetic, ordering, etc. are completely unprincipled. Absolutely full of pitfalls.23

But, bash does have some upsides: It’s stable, it’s simple, and it works the same on every machine. You can write a bash script and keep it running for years while never having to change any code. The code you wrote last year will be the same next year, which is not true of most popular programming languages. Look at Haskell.

So in the interest of defining a language that I would like to use, let’s discuss the anatomy of a shell scripting language:

  • It should be very basic.
  • It should run immediately (no visible compilation steps).
  • It should have no module system.
  • It should have no package system.4
  • It should have no abstraction capabilities (classes, fancy data types, polymorphic functions, etc.).
  • It should not change in backwards-incompatible ways.5

Why no module or package system? They make it harder for a system to be “done.” There’s always some other integration that you can do; some other feature to add. I’d prefer Hell to be cold-blooded software, there’s beauty in finished software.

Based on the above, I can define a scripting threshold, meaning, when you reach for a module system or a package system, or abstraction capabilities, or when you want more than what’s in the standard library, then you probably want a general purpose programming language instead.

Taking this into consideration, I opted for making a Haskell dialect6 because of the following reasons:

  • I know Haskell.
  • It’s my go-to.
  • It has a good story about equality, ordering, etc.
  • It has a good runtime capable of trivially doing concurrency.
  • It’s garbage collected.
  • It distinguishes bytes and text properly.
  • It can be compiled to a static Linux x86 binary.
  • It performs well.
  • It has static types!

I made the following decisions when designing the language:

  • Use a faithful Haskell syntax parser.
  • It’s better that way; you get re-use.
  • It has no imports/modules/packages.
  • It doesn’t support recursive definitions, but you can use fix to do so.
  • It supports basic type-classes (Eq, Ord, Show, Monad), which are needed for e.g. List.lookup and familiar equality things.
  • It does not support polytypes. That’s a kind of abstraction and not needed.
  • It use all the same names for things (List.lookup, Monad.forM, Async.race, etc.) that are already used in Haskell, which lets me re-use intuitions.

You can download statically-linked Linux binaries from the releases page. To read about the implementation internals, see Tour of Hell which is a set of slides I made for presenting Hell at work.


  1. I’m tired of issues like this.↩︎

  2. Just check out the huge list of linting issues in ShellCheck.↩︎

  3. See this blog post about code execution↩︎

  4. This excludes scripting languages like zx, which sits, unbelievably, on the nodejs ecosystem.↩︎

  5. See also: Escaping the Hamster Wheel of Backwards Incompatibility↩︎

  6. And not using some other alt. shell scripting language or using Elixir, or Oil.↩︎