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
This commit is contained in:
840
lessons/06-http-json.md
Normal file
840
lessons/06-http-json.md
Normal file
@@ -0,0 +1,840 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user