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

841 lines
17 KiB
Markdown

# 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:
```elm
-- 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`:
```elm
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
```bash
mkdir http-example
cd http-example
elm init
elm install elm/http
elm install elm/json
```
### Step 2: The Complete Example
```elm
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
```elm
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
// JavaScript
{ isLoading: false, hasError: true, data: null } // Confusing!
```
#### Init Returns a Command
```elm
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
```elm
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
```elm
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:
```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
```elm
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
```elm
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
```elm
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.
### Using Pipeline Style (Recommended)
Install:
```bash
elm install NoRedInk/elm-json-decode-pipeline
```
```elm
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
```elm
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
```elm
-- 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
```elm
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
```elm
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
```elm
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]
```
## Complete Example: GitHub User Search
```elm
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:
```json
{
"type": "general",
"setup": "Why did the chicken...",
"punchline": "To get to the other side!",
"id": 123
}
```
<details>
<summary>Solution</summary>
```elm
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
}
```
</details>
## Exercise 6.2: Nested JSON Decoding
Write a decoder for this JSON:
```json
{
"user": {
"profile": {
"name": "Alice",
"bio": "Elm enthusiast"
},
"stats": {
"followers": 100,
"following": 50
}
}
}
```
Into this type:
```elm
type alias UserInfo =
{ name : String
, bio : String
, followers : Int
, following : Int
}
```
<details>
<summary>Solution</summary>
```elm
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)
```
</details>
## 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](07-final-project.md), we'll build a complete application:
- A todo list with persistence
- Multiple pages
- All the concepts combined!
---
[← Previous: Lesson 5](05-lists-maybe.md) | [Next: Lesson 7 - Final Project →](07-final-project.md)