JSON.parse. You'll find built-in
Decoders for the basic types, like
Int, but there is no single function that says "just grab whatever is in this HTTP response." Instead, the standard library takes a modular approach, providing a few building blocks that can be arbitrarily composed. This design makes Elm's JSON decoding (or parsing) flexible and precise. Over the next few posts, we will discover this ourselves by implementing a simplified version of the
The "Value" type
Our goal is to implement
decodeValue and all the functions that build
Decoders. We'll begin by copying the main types in
Json.Decode's public interface. I'm getting these straight from the package.elm-lang.org documentation.
type Decoder a = Decoder type Value = Value decodeValue : Decoder a -> Value -> Result String a decodeValue _ _ = -- For more on `Result` and error handling in Elm, -- check out the guides: -- https://guide.elm-lang.org/error_handling/result.html Debug.crash "fix me" -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/0
These types look suspicious, since they each only have one constructor, but with this skeleton in place we can begin filling in the details. A look at the standard library internals reveals that
Json.Decode uses native modules for special runtime support. We, however, are going to create a pure Elm version, which means we'll need to create a
Value type that can represent any of the possible JSON types.
type Value = Jnull | Jnumber Float | Jstring String | Jboolean Bool -- we'll do object and array later -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/4
It's not yet clear what a
Decoder should be, so let's write our first test.
Give me a string
test "successfully decode a string" <| \_ -> Jstring "hi" |> decodeValue string |> Expect.equal (Ok "hi") -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/5
This test fails to compile since
string does not exist. Let's copy that signature over as well.
string : Decoder String string = Decoder -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/6
Ok! We're compiling again, but our test causes a runtime exception because of the call to
Debug.crash. We need to fill in the body for
decodeValue, but first, consider what computation needs to occur. In order to extract
"hi" from the
Value, we'll need to pattern-match within a
case expression. So the next question is, where should that pattern-matching live? Inside
string? Well, we want
decodeValue to be useful for any type of
Decoder, so we cannot hard-code any
String-specific stuff. We'll do this pattern-matching inside of
string, so that we can keep
decodeValue nice and generic. So let's define
Decoder as a function that takes a
Value and tries to extract some
a out of it.
type alias Decoder a = Value -> Result String a decodeValue : Decoder a -> Value -> Result String a decodeValue decoder value = decoder value string : Decoder String string value = case value of Jstring foundIt -> Ok foundIt _ -> Debug.crash "fix me" -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/7
Alright, we have our first passing test!
Simple error messaging
The call to
Debug.crash tells me that there's still work to do. Let's write a test for the unhappy path.
test "fail to decode a string" <| \_ -> Jnumber 0.0 |> decodeValue string |> Expect.err -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/8
That forces us to fix our crash-prone
string : Decoder String string value = case value of Jstring foundIt -> Ok foundIt _ -> Err "not a string" -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/9
This error handling is very rudimentary, but Elm values good error messaging! We'll come back to our error messages once we've built some more complex decoders.
Every good API needs thoughtful border control. Library authors think carefully about how their code should be used in order to prevent problematic scenarios. Since
type alias Decoder leaks details about how our module works, it invites misuse and confusion. The package website explains why this is the case:
Exported or not, client code can construct and inspect [type aliased values] without your library, which is also bad when it comes time to extend it. Instead, use a union type where the type is exported but the tags are not, known as an opaque type. It's not hidden since you can see that it's there, but you can't see into it, hence it's opaque.
type alias to
type can simplify our API externally and insulate our users from internal change. Keeping the
Decoder constructor unexposed turns the recommended usage into the only usage. Let's re-imagine our module with an opaque
module Jdecode exposing ( Decoder , decodeValue , string ) type Decoder a = Decoder (Value -> Result String a) string : Decoder String string = let attemptToDecode value = case value of Jstring foundIt -> Ok foundIt _ -> Err "not a string" in Decoder attemptToDecode decodeValue : Decoder a -> Value -> Result String a decodeValue (Decoder attemptToDecode) value = attemptToDecode value -- TRY IT OUT: https://ellie-app.com/3xpx2GTvbpLa1/10
We wind up with a little more code in our module to wrap and unwrap the
Decoder type, but remember, we're thinking about the users of this library now. This change also makes our module look more like the original
Json.Decode, which is a good sign.
I won't walk through the implementations for
bool, because they are very similar to
string. If you want to check your answers, check out my complete example on Ellie.
Primitive decoders are simple because each one is very focused. As we work through the other functions in this module, you will likely be surprised how simple each individual piece really is. I hope you'll stay tuned for Part 2: exploring data structures with recursive