Debugging DOM event handlers in Elm

Joël Quenneville

Custom DOM event handlers in Elm are made up of 3 parts:

  1. The Html.Events.on function
  2. The string name of the DOM event you want to react to
  3. A JSON decoder that gets values out of the event object. The decoded value gets sent to your update function.

For example:

Html.Events.on "click" clickDecoder

One important aspect of decoders is that they can fail. For example, you try to read a property on the event object that doesn’t exist. What should the event handler send to your update function? The Html.Events library has opted for perhaps the simplest solution - ignore events that can’t be successfully decoded.

Authors of custom events have leaned into this behavior, writing decoders that purposely fail. This allows them to write event handlers like onEnter that listen to the “input” event but only fires if the Enter key is pressed.

Failures

Silently ignoring events that fail to decode is a feature. Until it’s not. If you accidentally wrote a broken decoder, you won’t get any error when if fails. Instead you’ll just have an event handler that doesn’t seem to be firing any events. This problem is particularly likely if you’re writing a decoder where you are purposely failing the decoder but may be doing that in the wrong place.

Ideally, there would be a way to get some visibility into those failures. The problem is that JSON decoders aren’t aware of their failures. A decoder that tries to read a string from a non-existent field can’t say “log the error if I fail”.

brokenDecoder : Decoder String
brokenDecoder =
  Decode.field "doesntExist" Decode.string

Normally this isn’t a problem because we get back a Result, either from directly decoding some JSON or indirectly from an HTTP response. There we can easily add a Debug.log to the Err branch.

case Decode.decodeValue brokenDecoder someJson of
  Ok val    -> -- HANDLE SUCCESS
  Err error -> Debug.log "decode error" error

The problem is that when writing a DOM event handler, we don’t get access to that Result. We need some way of getting access to it in the decoder. Time to dive into some decoder fanciness!

Logging Decoder

If we had access to the raw event object we could manually call Decode.decodeValue on it to get the Result. But inside a decoder we don’t have access to the event directly. Only whatever properties we’ve decided to decode on it. This is where one of the weirder decoders in the Json.Decode library becomes useful.

Decode.value is a decoder that doesn’t do any decoding. Instead, it just gives you back the unparsed blob of JSON. Rather than asking for a particular field from the DOM event, we can ask for “all of it”. Chain an andThen onto it and now you have a reference to the event that you can use in your code:

Decode.value
  |> Decode.andThen (\event -> -- YAY!!)

The whole point of getting the event was so we could call Decode.decodeValue manually on it so that we could get access to the Result. Now we have access to the error and can log it. Success!

Decode.value
  |> Decode.andThen
    (\event ->
      case Decode.decodeValue brokenDecoder event of
        Ok val    -> -- HANDLE SUCCESS
        Err error -> Debug.log "decode error" error
      )

Final product

We still want to return a decoder so we re-wrap the values from our Ok and Err branches with Decode.succeed and Decode.fail respectively. Parameterize it to make it generic and we end up with a function that looks like this:

loggingDecoder : Decoder a -> Decoder a
loggingDecoder realDecoder =
  Decode.value
    |> Decode.andThen
      (\event ->
        case Decode.decodeValue realDecoder event of
          Ok decoded ->
            Decode.succeed decoded

          Err error ->
            error
              |> Decode.errorToString
              |> Debug.log "decoding error"
              |> Decode.fail
      )

Now we can wrap decoders we’d like to inspect with it. Any time the event handler drops an event because of a failed decoder you’ll see the error show up in your browser console.

Html.Events.on "click" (loggingDecoder brokenDecoder)

You can see it in action in this working example.