DEV Community

Cover image for Debugging without a “real” debugger
Zelenya
Zelenya

Posted on

Debugging without a “real” debugger

📹 Hate reading articles? Check out the complementary video, which covers the same content.


First, a confession. I don’t think I’ve ever found a bug using a “real” debugger. By now, I’ve probably found (and fixed) hundreds of bugs, mostly by reading code and printing them to the console like a caveman. I have years of experience working on top of JVM, and the same with JavaScript — I had access to all the wonderful tools both platforms provide. It’s not my cup of tea.

If you find adding markers, stepping over, or stepping into your code fun or useful, you do you. Did you know that GHC has the GHCi Debugger?

But if you wonder how to debug in Haskell without a debugger, this tutorial is for you. Especially if someone convinced you that “Haskell is pure, so you can’t print anything”.

🐞 Note: It also applies to PureScript, and I’ll include a couple of snippets.

This program 🐞 you

Here is a simple program we are going to debug:

program :: IO ()
program = do
  rawJSON <- fetchData -- [1]
  let items = decodeItems rawJSON -- [2]
  let result = keepInteresting items -- [3]
  report result -- [4]

-- fetchData :: IO ByteString
-- decodeItems :: ByteString -> [Item]
-- keepInteresting :: [Item] -> [Text]
-- report :: [Text] -> IO ()
Enter fullscreen mode Exit fullscreen mode
  • [1] We fetch some raw data.
  • [2] Decode it.
  • [3] Process it.
  • [4] Report the result.

🐞 We’ll (re)investigate a genuine bug I made while trying to create an example for this tutorial.

I mocked some data, provided basic implementations (we’ll see them later), etc. I expected running the program would print at least one result back, but surprisingly (and luckily), I got an empty result. I messed up. Debugging time!

print “Hello, world”

There is no random global state we have to worry about in Haskell. Functions only depend on their inputs.

program takes no inputs (parameters). So, I messed up somewhere “inside” the function, specifically in one of the used functions. Because there 4 steps, there are 4 major places for the bug to hide. We can keep narrowing down the scope.

🐞 Of course, there are more than “just” 4 places, but the program is so tiny, and the types are strong enough that it’s hard to mess up otherwise.

We can use putStrLn to check which step fails or produces unexpected results:

program :: IO ()
program = do
  rawJSON <- fetchData
  putStrLn $ "Step 1: " ++ show rawJSON
  let items = decodeItems rawJSON
  putStrLn $ "Step 2: " ++ show items
  let result = keepInteresting items
  putStrLn $ "Step 3: " ++ show result
  report result
Enter fullscreen mode Exit fullscreen mode

Let’s see the output:

Step 1: "[ { \"label\": \"Box\", \"score\": 2 }, { \"label\": \"Big box\", \"score\": 12 } ] }"
Step 2: []
Step 3: []
"Result: []"
Enter fullscreen mode Exit fullscreen mode

When I saw this, I thought: “Okay, step 1 looks okay, but then somewhere in step 2, we lost all the data”. Step 2 is the decodeItems function. Let’s zoom in on it:

decodeItems :: ByteString -> [Item]
decodeItems raw = fromRight [] $ eitherDecode raw
Enter fullscreen mode Exit fullscreen mode

I was trying to cut the corners and default to an empty list in case of any errors (because I didn’t expect any errors). Wouldn’t it be nice to see these errors now? Also, note that the function doesn’t return an IO.

I know. The first snippet was unfairly easy to debug because we can just putStrLn things when we use IO. But we don’t always have this luxury in Haskell:

decodeItems :: ByteString -> [Item]
decodeItems raw = do
  let tmp = eitherDecode raw
  putStrLn $ "TMP: " ++ show tmp -- compilation error
  fromRight [] tmp
Enter fullscreen mode Exit fullscreen mode

We get a compilation error — we can’t mix IO and not IO expressions like that. What do we do?

In this simple case, we can change the type signature (to IO [Item]), rewrite the function, add putStrLn, adopt callers to the new result type, find the bug, and eventually roll everything back... This works. But this is super annoying. Especially if you have more than a few nested functions and not oneliners.

This is where Debug.Trace comes in.

trace “pls hlp”

Debug.Trace is part of the base (standard library) and provides several functions for printing values.

For instance, we can use traceShowId to print the result of decoding:

decodeItems :: ByteString -> [Item]
decodeItems raw = fromRight [] $ traceShowId $ eitherDecode raw
Enter fullscreen mode Exit fullscreen mode

When we run this, we see a new trace in the log:

...
Left "Error in $: endOfInput at '}'"
...
Enter fullscreen mode Exit fullscreen mode

The function shows and returns the value: traceShowId :: Show a => a -> a, so we can seamlessly plug it in most places (given that a value is Showable).

The only issue is that we’re printing the value without any label or indication, which can easily be lost among all the other output. It would be nice if we could add a searchable tag or meaningful prefix like “!!!!!!!!!!!!DEBUG!!!!!!!!!!!!!!!!!!!”.

The Trace module provides other alternatives. For example, we can use trace to make a custom message:

decodeItems :: ByteString -> [Item]
decodeItems raw = 
  let tmp = eitherDecode raw
   in trace ("HLP: " ++ show tmp) $ fromRight [] tmp
Enter fullscreen mode Exit fullscreen mode

This time, we acquire a searchable label:

HELP: Left "Error in ..."
Enter fullscreen mode Exit fullscreen mode

This version’s output is easier to read and find but requires moving some code around.

If you’re from the future or lucky enough to use the latest ghc, you can use traceWith to get the best of both worlds:

decodeItems :: ByteString -> [Item]
decodeItems raw = 
  fromRight [] $ traceWith (\a -> "AAA: " ++ show a) $ eitherDecode raw
Enter fullscreen mode Exit fullscreen mode

[PureScript] spy “dupa”

In other words, if you’re using PureScript, spy is the only function you need – it prints and returns the value but also takes an additional label:

decodeItems :: String -> Array Item
decodeItems raw = fromRight [] $ spy "HOPE IT WORKS" $ readJSON raw
Enter fullscreen mode Exit fullscreen mode

🐞 We need to import Debug (spy) from purescript-debug.

When we run this, we see the label and the value:

...
HOPE IT WORKS: Left { ... }
...
Enter fullscreen mode Exit fullscreen mode

As a bonus, spy has a custom usage warning, so we remember not to push this into prod.

spy :: forall a. DebugWarning => String -> a -> a
Enter fullscreen mode Exit fullscreen mode

Next steps

So, where is the bug?

Here is the full decoding error: Left "Error in $: endOfInput at '}'"

The bug didn’t hide in decodeItems; it received corrupted data from its input — the output of fetchData. We I just didn’t notice it. Here is the raw data:

fetchData :: IO ByteString
fetchData = pure "[ { \"label\": \"Box\", \"score\": 2 }, { \"label\": \"Big box\", \"score\": 12 } ] }"
Enter fullscreen mode Exit fullscreen mode

It’s a broken json. I left an extra curly bracket at the end. We’re trying to decode a list of items, but we can’t.


This time, after narrowing down the scope once or twice, it was easy to spot the bug by reviewing the code. It was also easy to run everything. What if it wasn’t easy? What if we had a large app with more hoops to jump through?

We don’t have to struggle debugging functions somewhere in the middle of the production service — functions only depend on their inputs. We can find the problematic arguments and test the function in isolation.

Use REPL

If you want a more straightforward and faster feedback loop, you can use ghci (or GHCi), an interactive environment (aka REPL). How to use it is out of the scope of this tutorial and depends on personal preferences, but the rough idea is this:

  • Find the problematic function (by running the whole app or specific functions using ghci).
  • Modify the code (for example, to add some prints or traces).
  • Find the problematic inputs.
  • Repeat with a narrower scope.

🐞 As I mentioned in the intro, GHCi comes with a debugger.

Write tests

Alternatively, if you like to write tests, you can follow a similar process as with ghci, but instead of reproducing the problem by running functions with specific inputs in the REPL, paste them into the unit (or property-based) tests. As a bonus, you can keep the tests to catch future regressions.

EOF

For me, debugging in Haskell comes down to using prints and traces (or proper production observability — actual logging and tracing) to narrow down the scope of the bug and using tests or ghci to reproduce and fix the issues.

In the example, we started from program, found the problematic function using prints, stepped into it, found an issue using tracing, and the rest is history.

Appendix I. The rest of the implementations

keepInteresting :: [Item] -> [Text]
keepInteresting = map label . filter ((>) 10 . score)

report :: [Text] -> IO ()
report items = print $ "Result: " <> show items

data Item = Item {label :: Text, score :: Int} deriving (Show)

$(deriveJSON defaultOptions ''Item)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)