Files
elm/lessons/06-http-json.md
Mark Gerrard 7d7986f3ab Initial commit: Elm tutorial for JavaScript developers
This tutorial includes:
- 7 progressive lessons covering Elm fundamentals
- Exercises with starter code
- Solutions for exercises
- Final project: Task Manager app with localStorage persistence

Topics covered:
- Basic syntax and types
- Functions and functional programming concepts
- The Elm Architecture (Model-View-Update)
- Lists and Maybe types
- HTTP requests and JSON decoding
- Ports for JavaScript interop
2026-03-11 11:07:15 +00:00

17 KiB

Lesson 6: HTTP & JSON

Learning Goals

By the end of this lesson, you will:

  • Understand Commands (Cmd) in Elm
  • Make HTTP requests
  • Decode JSON responses
  • Handle loading states and errors
  • Understand how Elm manages side effects

Side Effects in Elm

Remember: Elm functions are pure - they can't do side effects directly. So how do we:

  • Make HTTP requests?
  • Generate random numbers?
  • Get the current time?
  • Write to local storage?

Answer: Commands (Cmd)

The Command Pattern

Instead of performing side effects, you describe them:

-- Instead of actually making a request:
-- response = http.get("https://api.example.com")  -- NOT how Elm works

-- You return a command describing what you want:
update msg model =
    case msg of
        FetchData ->
            ( model, Http.get { url = "...", expect = ... } )
            -- Returns the model AND a command

Elm runtime executes the command and sends the result back as a message.

Browser.element: Elm with Commands

We need to upgrade from Browser.sandbox to Browser.element:

main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

Key Differences

Browser.sandbox Browser.element
init : Model init : flags -> (Model, Cmd Msg)
update : Msg -> Model -> Model update : Msg -> Model -> (Model, Cmd Msg)
No side effects Can perform side effects via Cmd

Your First HTTP Request

Let's fetch a random quote from an API.

Step 1: Set Up the Project

mkdir http-example
cd http-example
elm init
elm install elm/http
elm install elm/json

Step 2: The Complete Example

module Main exposing (main)

import Browser
import Html exposing (Html, button, div, p, text)
import Html.Events exposing (onClick)
import Http


-- MODEL

type Model
    = Loading
    | Success String
    | Failure String


init : () -> ( Model, Cmd Msg )
init _ =
    ( Loading, fetchQuote )


-- UPDATE

type Msg
    = GotQuote (Result Http.Error String)
    | FetchNewQuote


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotQuote result ->
            case result of
                Ok quote ->
                    ( Success quote, Cmd.none )

                Err error ->
                    ( Failure (errorToString error), Cmd.none )

        FetchNewQuote ->
            ( Loading, fetchQuote )


errorToString : Http.Error -> String
errorToString error =
    case error of
        Http.BadUrl url ->
            "Bad URL: " ++ url

        Http.Timeout ->
            "Request timed out"

        Http.NetworkError ->
            "Network error"

        Http.BadStatus status ->
            "Bad status: " ++ String.fromInt status

        Http.BadBody message ->
            "Bad body: " ++ message


-- HTTP

fetchQuote : Cmd Msg
fetchQuote =
    Http.get
        { url = "https://api.quotable.io/random"
        , expect = Http.expectJson GotQuote quoteDecoder
        }


quoteDecoder : Json.Decode.Decoder String
quoteDecoder =
    Json.Decode.field "content" Json.Decode.string


-- VIEW

view : Model -> Html Msg
view model =
    div []
        [ case model of
            Loading ->
                text "Loading..."

            Success quote ->
                div []
                    [ p [] [ text quote ]
                    , button [ onClick FetchNewQuote ] [ text "New Quote" ]
                    ]

            Failure error ->
                div []
                    [ p [] [ text ("Error: " ++ error) ]
                    , button [ onClick FetchNewQuote ] [ text "Try Again" ]
                    ]
        ]


-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none


-- MAIN

main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

Breaking It Down

The Model as State Machine

type Model
    = Loading
    | Success String
    | Failure String

Using a custom type for the model means we can only be in ONE state at a time. No more:

// JavaScript
{ isLoading: false, hasError: true, data: null }  // Confusing!

Init Returns a Command

init : () -> ( Model, Cmd Msg )
init _ =
    ( Loading, fetchQuote )  -- Start in Loading state, fetch immediately

The () is called "unit" - it means no flags are passed. We return a tuple of model and command.

Update Returns a Command

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotQuote result ->
            case result of
                Ok quote ->
                    ( Success quote, Cmd.none )  -- No more commands
                
                Err error ->
                    ( Failure (errorToString error), Cmd.none )

Cmd.none means "no command to execute."

The HTTP Request

fetchQuote : Cmd Msg
fetchQuote =
    Http.get
        { url = "https://api.quotable.io/random"
        , expect = Http.expectJson GotQuote quoteDecoder
        }
  • Http.get creates a GET request command
  • expect tells Elm what to do with the response
  • GotQuote is the message constructor that will wrap the result
  • quoteDecoder parses the JSON

JSON Decoding

Elm doesn't just convert JSON to any type automatically. You must define a decoder that describes the JSON structure.

Why Decoders?

JavaScript:

const data = JSON.parse(response);
console.log(data.user.name);  // Might crash if structure is wrong!

Elm: The decoder either succeeds with the exact type you expect, or fails gracefully.

Basic Decoders

import Json.Decode exposing (Decoder, string, int, float, bool)

-- Primitive decoders
string : Decoder String    -- Decodes "hello" to "hello"
int : Decoder Int          -- Decodes 42 to 42
float : Decoder Float      -- Decodes 3.14 to 3.14
bool : Decoder Bool        -- Decodes true to True

Decoding Objects

import Json.Decode exposing (Decoder, field, string, int)

-- JSON: { "name": "Alice" }
nameDecoder : Decoder String
nameDecoder =
    field "name" string

-- JSON: { "user": { "name": "Alice" } }
nestedNameDecoder : Decoder String
nestedNameDecoder =
    field "user" (field "name" string)

Decoding Multiple Fields

import Json.Decode exposing (Decoder, map2, field, string, int)

type alias User =
    { name : String
    , age : Int
    }

-- JSON: { "name": "Alice", "age": 30 }
userDecoder : Decoder User
userDecoder =
    map2 User
        (field "name" string)
        (field "age" int)

map2 takes:

  1. A constructor function (User)
  2. Decoder for first field
  3. Decoder for second field

For more fields, use map3, map4, etc., or the elm/json-decode-pipeline package.

Install:

elm install NoRedInk/elm-json-decode-pipeline
import Json.Decode exposing (Decoder, string, int)
import Json.Decode.Pipeline exposing (required, optional)

type alias User =
    { name : String
    , age : Int
    , email : Maybe String
    }

userDecoder : Decoder User
userDecoder =
    Json.Decode.succeed User
        |> required "name" string
        |> required "age" int
        |> optional "email" (Json.Decode.map Just string) Nothing

Decoding Lists

import Json.Decode exposing (Decoder, list, string)

-- JSON: ["apple", "banana", "cherry"]
fruitsDecoder : Decoder (List String)
fruitsDecoder =
    list string

-- JSON: [{ "name": "Alice" }, { "name": "Bob" }]
usersDecoder : Decoder (List User)
usersDecoder =
    list userDecoder

Decoding with Maybe

-- JSON: { "name": "Alice", "nickname": null }
-- Or: { "name": "Alice" }  (nickname missing)

type alias Person =
    { name : String
    , nickname : Maybe String
    }

personDecoder : Decoder Person
personDecoder =
    map2 Person
        (field "name" string)
        (Json.Decode.maybe (field "nickname" string))

Testing Decoders

import Json.Decode exposing (decodeString)

result = decodeString userDecoder """{"name": "Alice", "age": 30}"""
-- Ok { name = "Alice", age = 30 }

badResult = decodeString userDecoder """{"name": "Alice"}"""
-- Err ... (missing field "age")

POST Requests with JSON Body

import Http
import Json.Encode as Encode

createUser : String -> Int -> Cmd Msg
createUser name age =
    Http.post
        { url = "https://api.example.com/users"
        , body = Http.jsonBody (encodeUser name age)
        , expect = Http.expectJson GotNewUser userDecoder
        }


encodeUser : String -> Int -> Encode.Value
encodeUser name age =
    Encode.object
        [ ( "name", Encode.string name )
        , ( "age", Encode.int age )
        ]

JSON Encoding

import Json.Encode as Encode

-- Primitives
Encode.string "hello"    -- "hello"
Encode.int 42            -- 42
Encode.float 3.14        -- 3.14
Encode.bool True         -- true
Encode.null              -- null

-- Objects
Encode.object
    [ ( "name", Encode.string "Alice" )
    , ( "age", Encode.int 30 )
    ]
-- { "name": "Alice", "age": 30 }

-- Lists
Encode.list Encode.int [1, 2, 3]
-- [1, 2, 3]
module GitHubSearch exposing (main)

import Browser
import Html exposing (Html, button, div, img, input, li, text, ul)
import Html.Attributes exposing (placeholder, src, value, width)
import Html.Events exposing (onClick, onInput)
import Http
import Json.Decode exposing (Decoder, field, int, list, string)


-- MODEL

type alias User =
    { login : String
    , avatarUrl : String
    , id : Int
    }


type Model
    = Initial
    | Loading String
    | Success String (List User)
    | Failure String String


init : () -> ( Model, Cmd Msg )
init _ =
    ( Initial, Cmd.none )


-- UPDATE

type Msg
    = UpdateSearch String
    | Search
    | GotUsers (Result Http.Error (List User))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateSearch query ->
            case model of
                Initial ->
                    ( Initial, Cmd.none )

                Loading q ->
                    ( Loading query, Cmd.none )

                Success _ users ->
                    ( Success query users, Cmd.none )

                Failure _ error ->
                    ( Failure query error, Cmd.none )

        Search ->
            let
                query =
                    getQuery model
            in
            if String.isEmpty query then
                ( model, Cmd.none )
            else
                ( Loading query, searchUsers query )

        GotUsers result ->
            let
                query =
                    getQuery model
            in
            case result of
                Ok users ->
                    ( Success query users, Cmd.none )

                Err error ->
                    ( Failure query (errorToString error), Cmd.none )


getQuery : Model -> String
getQuery model =
    case model of
        Initial ->
            ""

        Loading q ->
            q

        Success q _ ->
            q

        Failure q _ ->
            q


errorToString : Http.Error -> String
errorToString error =
    case error of
        Http.BadUrl url ->
            "Bad URL: " ++ url

        Http.Timeout ->
            "Request timed out"

        Http.NetworkError ->
            "Network error"

        Http.BadStatus status ->
            "Bad status: " ++ String.fromInt status

        Http.BadBody message ->
            "Bad body: " ++ message


-- HTTP

searchUsers : String -> Cmd Msg
searchUsers query =
    Http.get
        { url = "https://api.github.com/search/users?q=" ++ query
        , expect = Http.expectJson GotUsers usersDecoder
        }


usersDecoder : Decoder (List User)
usersDecoder =
    field "items" (list userDecoder)


userDecoder : Decoder User
userDecoder =
    Json.Decode.map3 User
        (field "login" string)
        (field "avatar_url" string)
        (field "id" int)


-- VIEW

view : Model -> Html Msg
view model =
    div []
        [ div []
            [ input
                [ placeholder "Search GitHub users..."
                , value (getQuery model)
                , onInput UpdateSearch
                ]
                []
            , button [ onClick Search ] [ text "Search" ]
            ]
        , viewResults model
        ]


viewResults : Model -> Html Msg
viewResults model =
    case model of
        Initial ->
            div [] [ text "Enter a search term" ]

        Loading _ ->
            div [] [ text "Searching..." ]

        Success _ users ->
            if List.isEmpty users then
                div [] [ text "No users found" ]
            else
                ul [] (List.map viewUser users)

        Failure _ error ->
            div [] [ text ("Error: " ++ error) ]


viewUser : User -> Html Msg
viewUser user =
    li []
        [ img [ src user.avatarUrl, width 50 ] []
        , text (" " ++ user.login)
        ]


-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none


-- MAIN

main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

Exercise 6.1: Basic HTTP Request

Create an app that:

  1. Fetches a random joke from https://official-joke-api.appspot.com/random_joke
  2. Displays the setup and punchline
  3. Has a "New Joke" button

The API returns:

{
    "type": "general",
    "setup": "Why did the chicken...",
    "punchline": "To get to the other side!",
    "id": 123
}
Solution
module JokeApp exposing (main)

import Browser
import Html exposing (Html, button, div, p, text)
import Html.Events exposing (onClick)
import Http
import Json.Decode exposing (Decoder, field, string)


type alias Joke =
    { setup : String
    , punchline : String
    }


type Model
    = Loading
    | Success Joke
    | Failure String


init : () -> ( Model, Cmd Msg )
init _ =
    ( Loading, fetchJoke )


type Msg
    = GotJoke (Result Http.Error Joke)
    | FetchNewJoke


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotJoke result ->
            case result of
                Ok joke ->
                    ( Success joke, Cmd.none )

                Err _ ->
                    ( Failure "Failed to fetch joke", Cmd.none )

        FetchNewJoke ->
            ( Loading, fetchJoke )


fetchJoke : Cmd Msg
fetchJoke =
    Http.get
        { url = "https://official-joke-api.appspot.com/random_joke"
        , expect = Http.expectJson GotJoke jokeDecoder
        }


jokeDecoder : Decoder Joke
jokeDecoder =
    Json.Decode.map2 Joke
        (field "setup" string)
        (field "punchline" string)


view : Model -> Html Msg
view model =
    div []
        [ case model of
            Loading ->
                text "Loading..."

            Success joke ->
                div []
                    [ p [] [ text joke.setup ]
                    , p [] [ text joke.punchline ]
                    , button [ onClick FetchNewJoke ] [ text "New Joke" ]
                    ]

            Failure error ->
                div []
                    [ text error
                    , button [ onClick FetchNewJoke ] [ text "Try Again" ]
                    ]
        ]


subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none


main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

Exercise 6.2: Nested JSON Decoding

Write a decoder for this JSON:

{
    "user": {
        "profile": {
            "name": "Alice",
            "bio": "Elm enthusiast"
        },
        "stats": {
            "followers": 100,
            "following": 50
        }
    }
}

Into this type:

type alias UserInfo =
    { name : String
    , bio : String
    , followers : Int
    , following : Int
    }
Solution
import Json.Decode exposing (Decoder, field, int, map4, string)


userInfoDecoder : Decoder UserInfo
userInfoDecoder =
    map4 UserInfo
        (field "user" (field "profile" (field "name" string)))
        (field "user" (field "profile" (field "bio" string)))
        (field "user" (field "stats" (field "followers" int)))
        (field "user" (field "stats" (field "following" int)))


-- Or using Json.Decode.at for cleaner nested access:
userInfoDecoder2 : Decoder UserInfo
userInfoDecoder2 =
    map4 UserInfo
        (Json.Decode.at [ "user", "profile", "name" ] string)
        (Json.Decode.at [ "user", "profile", "bio" ] string)
        (Json.Decode.at [ "user", "stats", "followers" ] int)
        (Json.Decode.at [ "user", "stats", "following" ] int)

Key Takeaways

  1. Commands describe side effects - Elm runtime performs them
  2. HTTP requests return Results - Success or failure
  3. JSON decoders are type-safe - Compiler ensures correct parsing
  4. Model states - Use custom types to represent Loading/Success/Failure
  5. Update returns (Model, Cmd) - New state plus commands to run

What's Next?

In Lesson 7, we'll build a complete application:

  • A todo list with persistence
  • Multiple pages
  • All the concepts combined!

← Previous: Lesson 5 | Next: Lesson 7 - Final Project →