How to generate JSON from Elm values using Json.Encode

In Practical Elm, I discuss at length how to convert a complex JSON string containing nested objects, arrays and recursion into Elm values.

But what about the opposite conversion, from Elm values to a JSON string? We often need to make requests to a server which include data in JSON format. For example, you might be working with a database like MongoDB or CouchDB, and you might need to get a JSON document from it, modify it in some way, and then send it back to the server.

In JavaScript, converting JSON to a string is as simple as JSON.stringify(myObject), but in Elm it takes a bit more work.

The Elm core library includes the Json.Encode package. The process of using it is somewhat similar to using Json.Decode:

  • First, we use helper functions to produce a Json.Encode.Value encoding the appropriate JSON structure
  • Then, we convert this value into a string using encode : Int -> Value -> String (the Int argument is the number of spaces to use for indentation)

To produce a Json.Encode.Value, there are a number of primitive functions to deal with "leaf nodes":

string : String -> Value
int : Int -> Value
float : Float -> Value
bool : Bool -> Value
null : Value

To combine these values into objects and arrays, we have three more functions:

list : List Value -> Value
array : Array Value -> Value
object : List (String, Value) -> Value

Since JSON is a fairly simple format, this is enough to describe any JSON document.

In Practical Elm, I show how to decode JSON documents that look like this:

{                                                           
  "Plan": {                                                 
    "Node Type": "Hash Join",                               
    "Parallel Aware": false,                                
    "Join Type": "Inner",                                   
    "Startup Cost": 1.04,                                   
    "Total Cost": 2.11,                                     
    "Plan Rows": 2,                                         
    "Plan Width": 176,                                      
    "Actual Startup Time": 0.061,                           
    "Actual Total Time": 0.066,                             
    "Actual Rows": 8,                                       
    "Actual Loops": 1,                                      
    "Hash Cond": "(zones.project_id = projects.project_id)",
    "Plans": [                                              
        {                                                     
        "Node Type": "Seq Scan",                            
        "Parent Relationship": "Outer",                     
        "Parallel Aware": false,                            
        "Relation Name": "zones",                           
        "Alias": "zones",                                   
        "Startup Cost": 0.00,                               
        "Total Cost": 1.03,                                 
        "Plan Rows": 3,                                     
        "Plan Width": 112,                                  
        "Actual Startup Time": 0.013,                       
        "Actual Total Time": 0.015,                         
        "Actual Rows": 4,                                   
        "Actual Loops": 1                                   
        },                                                    
        {                                                     
        "Node Type": "Hash",                                
        "Parent Relationship": "Inner",                     
        "Parallel Aware": false,                            
        "Startup Cost": 1.02,                               
        "Total Cost": 1.02,                                 
        "Plan Rows": 2,                                     
        "Plan Width": 72,                                   
        "Actual Startup Time": 0.013,                       
        "Actual Total Time": 0.013,                         
        "Actual Rows": 4,                                   
        "Actual Loops": 1,                                  
        "Hash Buckets": 1024,                               
        "Original Hash Buckets": 1024,                      
        "Hash Batches": 1,                                  
        "Original Hash Batches": 1,                         
        "Peak Memory Usage": 9,                             
        "Plans": [                                          
            {                                                 
            "Node Type": "Seq Scan",                        
            "Parent Relationship": "Outer",                 
            "Parallel Aware": false,                        
            "Relation Name": "projects",                    
            "Alias": "projects",                            
            "Startup Cost": 0.00,                           
            "Total Cost": 1.02,                             
            "Plan Rows": 2,                                 
            "Plan Width": 72,                               
            "Actual Startup Time": 0.004,                   
            "Actual Total Time": 0.005,                     
            "Actual Rows": 4,                               
            "Actual Loops": 1                               
            }                                                 
        ]                                                   
        }                                                     
    ]                                                       
  },                                                        
  "Planning Time": 0.174,                                   
  "Triggers": [                                             
  ],                                                        
  "Execution Time": 0.169                                   
}                                                           

The top level consists of a "Plan" key plus some optional fields like "Execution Time" and "Planning Time". That's straightforward, but the value under the "Plan" key is more interesting:

  1. Each node has a set of attributes common to all nodes, as well as some node specific attributes. For example, every node has "Startup Cost" and "Total Cost", but a "Seq Scan" node also has "Filter" and "Relation Name" which are specific to it. There are many possible node types with different sets of attributes, and some of the attributes may or may not be present.

  2. The structure is recursive: a node may optionally contain a "Plans" field which is a list of nodes, each of which may have its own "Plans" field, and so on.

Eventually, I write the decoders to produce a value of type PlanJson which in turn incorporates a bunch of other types:

type alias PlanJson =
    { executionTime : Float
    , plan : Plan
    , planningTime : Float
    , triggers : List String
    }


type Plan
    = PCte CteNode
    | PGeneric CommonFields
    | PResult ResultNode
    | PSeqScan SeqScanNode
    | PSort SortNode


type Plans
    = Plans (List Plan)


type alias CommonFields =
    { actualLoops : Int
    , actualTotalTime : Float
    , nodeType : String
    , plans : Plans
    , relationName : String
    , schema : String
    , startupCost : Float
    , totalCost : Float
    }


type alias ResultNode =
    { common : CommonFields
    , parentRelationship : String
    }


type alias CteNode =
    { common : CommonFields
    , alias_ : String
    , cteName : String
    }


type alias SeqScanNode =
    { common : CommonFields
    , alias_ : String
    , filter : String
    , relationName : String
    , rowsRemovedByFilter : Int
    }


type alias SortNode =
    { common : CommonFields
    , sortKey : List String
    , sortMethod : String
    , sortSpaceUsed : Int
    , sortSpaceType : String
    }

But let's see how we can instead encode a subset of data from a PlanJson value into a JSON string. We'll start from the top level structure:

encode : PlanJson -> Json.Value
encode planJson =
    object
        [ ( "Execution Time", float planJson.executionTime )
        , ( "Planning Time", float planJson.planningTime )
        , ( "Triggers", list <| List.map string planJson.triggers )
        , ( "Plan", encodePlan planJson.plan )
        ]

Note that in order to encode "Triggers" which is an array of strings, we first encode individual values in the list and then convert the resulting List Value into Value with list: ( "Triggers", list <| List.map string planJson.triggers )

Next, encodePlan looks like this:

encodePlan : Plan -> Json.Value
encodePlan plan =
    case plan of
        PCte cteNode ->
            object <|
                encodeCommonFields cteNode.common
                    ++ [ ( "Alias", string cteNode.alias_ )
                       , ( "CTE Name", string cteNode.cteName )
                       ]

        PGeneric common ->
            object <| encodeCommonFields common

        PResult resultNode ->
            object <| encodeCommonFields resultNode.common

        PSeqScan seqScanNode ->
            object <|
                encodeCommonFields seqScanNode.common
                    ++ [ ( "Alias", string seqScanNode.alias_ )
                       , ( "Filter", string seqScanNode.filter )
                       ]

        PSort sortNode ->
            object <| encodeCommonFields sortNode.common

For each type of node, we have to encode the common attributes and then combine them in a single object with node-specific attributes. I didn't bother with node-specific attributes for PResult and PSort node types.

encodeCommonFields looks like this (again, I'm only including a subset of fields in the JSON):

encodeCommonFields : CommonFields -> List ( String, Json.Value )
encodeCommonFields common =
    let
        (Plans plans) =
            common.plans
    in
        [ ( "Actual Loops", int common.actualLoops )
        , ( "Actual Total Time", float common.actualTotalTime )
        , ( "Plans", list <| List.map encodePlan plans )
        ]

This function describes the recursion in the JSON structure as it calls encodePlan for the "Plans" key. Fortunately, it's more straightforward to deal with recursion when encoding JSON. Unlike decoding, it doesn't require the use of lazy, for example.

With these three functions, we've described the JSON that we would like to produce. In order to generate a string, we can call encode:

Json.encode 2 <| EncodePlan.encode planJson

This will produce JSON strings like this one:

{
  "Execution Time": 0.169,
  "Planning Time": 0.174,
  "Triggers": [],
  "Plan": {
    "Actual Loops": 1,
    "Actual Rows": 8,
    "Plans": [
      {
        "Actual Loops": 1,
        "Actual Rows": 4,
        "Plan": [],
        "Alias": "zones",
        "Filter": ""
      },
      {
        "Actual Loops": 1,
        "Actual Rows": 4,
        "Plans": [
          {
            "Actual Loops": 1,
            "Actual Rows": 4,
            "Plans": [],
            "Alias": "projects",
            "Filter": ""
          }
        ]
      }
    ]
  }
}

However, in order to send this JSON to a server in an HTTP request (using the Http package from the Elm core library), we don't need to convert it to a string. We can use Http.jsonBody when constructing the request, and pass it the planJson converted into Json.Encode.Value:

savePlan : String -> PlanJson -> Cmd Msg
savePlan serverUrl planJson =
    let
        body =
            Http.jsonBody <| EncodePlan.encode planJson

        request =
            Http.post (serverUrl ++ "plan") body Json.Decode.succeed
    in
        Http.send PlanSaved request

Finally, as an aside: what if we wanted to send a PlanJson value to JavaScript through a port? In that case, we don't need to convert the value to JSON, all we need to do is define an appropriate port:

port sendPlanJson : PlanJson -> Cmd msg

Then we can generate a command to send planJson through the port with sendPlanJson planJson.

And that's it!

If you want to know how to decode complex JSON, you can learn all the grisly details in Practical Elm right now.

Comments or questions? I'm @alexkorban on Twitter.