Effectful decoders in PureScript

Decoders are primarily used to define an integration with another API in cases where the functionality that you need isn't directly available within PureScript. In these cases, the expected structure of the payloads passed between integrations is defined statically; if the functionality had been implemented in PureScript, then we'd say that it was defined at compile-time. So, decoder failures look a lot like compiler errors, which also means that we generally can't recover from them: If the structure of a payload is wrong now, then odds are very good that it will be wrong next time we attempt to decode it as well.

So, in my opinion, most of the time we should be crashing in response to decoder failures, since the application can't really continue because its logic is incorrect. In fact, our best shot at being able to continue is probably to use a crash to restart the program and push it back to a state where it might somehow avoid the code path that caused the decoder error (this is doubly effective if the failure can be isolated to a retryable job).

But, being good and unopinionated provisioners, decoder libraries generally hand back an Either or an Exception instead of crashing on a decoder failure, because you might want to do something besides crash; encapsulating these failure possibilities introduces significant overhead in decoder computations, slowing them down considerably, and in most cases, that performance hit is the thing that's actually harder to deal with in the long run.

So, I'm going to start crashing by default and defining my own effectful decoders. Here's an example of how one of these effectful decoders might be implemented in a benchmark that compares performance with Data.Codec.Argonaut:

module Main where

import Prelude

import Data.Argonaut.Core (Json)
import Data.Codec.Argonaut as CA
import Data.Codec.Argonaut.Record as CAR
import Data.Function.Uncurried (Fn1, mkFn1)
import Data.JSDate (JSDate)
import Effect (Effect)
import Effect.Console (log, logShow)
import Effect.Uncurried (EffectFn1, runEffectFn1, EffectFn2, runEffectFn2)
import Foreign (Foreign, tagOf, unsafeToForeign)
import Performance.Minibench (bench)
import Unsafe.Coerce (unsafeCoerce)

type Post =
  { title :: String
  , body :: String
  }

foreign import __readDate :: EffectFn2 (Fn1 Foreign String) Foreign JSDate
foreign import __readString :: EffectFn1 Foreign String
foreign import __readProperty :: EffectFn2 Foreign String Foreign

readString :: Foreign -> Effect String
readString = runEffectFn1 __readString

readDate :: Foreign -> Effect JSDate
readDate = runEffectFn2 __readDate (mkFn1 tagOf)

readProperty :: Foreign -> String -> Effect Foreign
readProperty = runEffectFn2 __readProperty

decodePost :: Foreign -> Effect { title :: String, body :: String, date :: JSDate }
decodePost foreignPost = do
  title <- readProperty foreignPost "title" >>= readString
  body <- readProperty foreignPost "body" >>= readString
  date <- readProperty foreignPost "date" >>= readDate

  pure { title, body, date }

foreign import __post :: Foreign

main :: Effect Unit
main = do
  let
    postForeign = unsafeToForeign { title: "Test post title", body: "Test post body" }

    postJson :: Json
    postJson = unsafeCoerce { title: "Test post title", body: "Test post body" }

  log "\n-- Foreign decoder --\n"
  bench \_ -> decodePost postForeign

  log "\n-- Codec argonaut --\n"

  let
    codec = CA.object "Post"
      ( CAR.record
          { title: CA.string
          , body: CA.string
          }
      )

bench \_ -> CA.decode codec postJson

And then in the foreign module:

export const __readDate = (tagOf, v) => {
  if (tagOf(v) === "Date")
    return v;
  else
    throw new Error(`Invalid date, ${v}`);
};

export const __readString = (v) => {
  if (typeof v === "string")
    return v;
  else
    throw new Error(`Invalid string, ${v}`);
};

export const __readProperty = (v, k) => {
  if (Object.getOwnPropertyNames(v).includes(k))
    return v[k];
  else throw new Error(`Tried to read non-existent key, ${k}`);
};

export const __post = {
  title: "My post title",
  body: "My post body",
  date: new Date,
};

The overall code weight is a bit heavier here, but we could easily extract our effectful analogues for the readXX functions from Foreign. The simplicity of the idiom for writing the decoders in the foreign module is a strength: It's easy to add new decoders for arbitrary types. And, the performance results are compelling, with our effectful decoder's mean performance twenty-three times faster than the Data.Codec.Argonaut equivalent:

-- Foreign decoder --

mean   = 147.47 ns
stddev = 422.24 ns
min    = 47.00 ns
max    = 11.32 μs

-- Codec argonaut --

mean   = 3.39 μs
stddev = 13.48 μs
min    = 670.00 ns
max    = 294.23 μs

Overall, I think this results in simpler, faster code with fewer dependencies that's easier to extend for custom types, reduces monadic complexity, and it comes at the cost of crashing when we were likely going to crash anyway.