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:
274
lessons/01-introduction.md
Normal file
274
lessons/01-introduction.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Lesson 1: Introduction & Your First Elm Program
|
||||
|
||||
## Learning Goals
|
||||
|
||||
By the end of this lesson, you will:
|
||||
- Understand the Elm REPL
|
||||
- Create your first Elm project
|
||||
- Compile and run Elm code
|
||||
- Understand the project structure
|
||||
|
||||
## The Elm REPL
|
||||
|
||||
Like Node.js, Elm has a REPL (Read-Eval-Print Loop) for experimenting with code.
|
||||
|
||||
Start it by running:
|
||||
```bash
|
||||
elm repl
|
||||
```
|
||||
|
||||
Try these expressions:
|
||||
|
||||
```elm
|
||||
> 1 + 1
|
||||
2 : number
|
||||
|
||||
> "Hello" ++ " World"
|
||||
"Hello World" : String
|
||||
|
||||
> String.length "Elm"
|
||||
3 : Int
|
||||
```
|
||||
|
||||
Notice something different from JavaScript? Elm tells you the **type** of each result!
|
||||
|
||||
### Key Differences from JavaScript Console
|
||||
|
||||
| JavaScript | Elm REPL |
|
||||
|-----------|----------|
|
||||
| `"a" + "b"` | `"a" ++ "b"` (string concat uses `++`) |
|
||||
| `1 + "1"` = `"11"` | Error! Can't mix types |
|
||||
| `console.log(x)` | Just type the expression |
|
||||
|
||||
Type `:exit` to leave the REPL.
|
||||
|
||||
## Your First Elm Project
|
||||
|
||||
Let's create a real project:
|
||||
|
||||
```bash
|
||||
mkdir hello-elm
|
||||
cd hello-elm
|
||||
elm init
|
||||
```
|
||||
|
||||
This creates:
|
||||
```
|
||||
hello-elm/
|
||||
├── elm.json # Project configuration (like package.json)
|
||||
└── src/ # Your Elm source files go here
|
||||
```
|
||||
|
||||
### Understanding elm.json
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": ["src"],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0"
|
||||
},
|
||||
"indirect": { ... }
|
||||
},
|
||||
"test-dependencies": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Comparison to JavaScript:**
|
||||
- `source-directories` is like setting up your bundler's entry points
|
||||
- `dependencies` works like `package.json` but with direct/indirect separation
|
||||
- Unlike npm, Elm enforces **semantic versioning automatically**
|
||||
|
||||
## Hello World
|
||||
|
||||
Create `src/Main.elm`:
|
||||
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
|
||||
import Html exposing (text)
|
||||
|
||||
|
||||
main =
|
||||
text "Hello, Elm!"
|
||||
```
|
||||
|
||||
Let's break this down:
|
||||
|
||||
### 1. Module Declaration
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
```
|
||||
- Every Elm file is a **module**
|
||||
- `exposing (main)` says "make `main` available to other modules"
|
||||
- **JavaScript equivalent:** `export { main }`
|
||||
|
||||
### 2. Imports
|
||||
```elm
|
||||
import Html exposing (text)
|
||||
```
|
||||
- Import the `Html` module
|
||||
- `exposing (text)` lets us use `text` directly instead of `Html.text`
|
||||
- **JavaScript equivalent:** `import { text } from 'html'`
|
||||
|
||||
### 3. The main Function
|
||||
```elm
|
||||
main =
|
||||
text "Hello, Elm!"
|
||||
```
|
||||
- `main` is the entry point (like `ReactDOM.render` or your app's root)
|
||||
- `text` creates an HTML text node
|
||||
- No `return` keyword needed - the last expression is the return value
|
||||
|
||||
## Running Your Code
|
||||
|
||||
### Option 1: Elm Reactor (Development Server)
|
||||
|
||||
```bash
|
||||
elm reactor
|
||||
```
|
||||
|
||||
Open http://localhost:8000 and click on `src/Main.elm`.
|
||||
|
||||
### Option 2: Compile to HTML
|
||||
|
||||
```bash
|
||||
elm make src/Main.elm
|
||||
```
|
||||
|
||||
This creates an `index.html` file you can open in a browser.
|
||||
|
||||
### Option 3: Elm Live (Hot Reloading)
|
||||
|
||||
```bash
|
||||
elm-live src/Main.elm -- --open
|
||||
```
|
||||
|
||||
This opens your browser and reloads on file changes (like webpack-dev-server).
|
||||
|
||||
## Exercise 1.1: Experiment in the REPL
|
||||
|
||||
Start `elm repl` and try:
|
||||
|
||||
1. Calculate `123 * 456`
|
||||
2. Concatenate three strings: "I", " love", " Elm"
|
||||
3. Find the length of "functional programming"
|
||||
4. Try `1 + "hello"` - what happens?
|
||||
|
||||
<details>
|
||||
<summary>Solutions</summary>
|
||||
|
||||
```elm
|
||||
> 123 * 456
|
||||
56088 : number
|
||||
|
||||
> "I" ++ " love" ++ " Elm"
|
||||
"I love Elm" : String
|
||||
|
||||
> String.length "functional programming"
|
||||
22 : Int
|
||||
|
||||
> 1 + "hello"
|
||||
-- TYPE MISMATCH --
|
||||
The (+) operator is expecting both arguments to be numbers...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 1.2: Modify Hello World
|
||||
|
||||
1. Change the greeting to include your name
|
||||
2. Try using `String.toUpper` to make it ALL CAPS
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
|
||||
import Html exposing (text)
|
||||
|
||||
|
||||
main =
|
||||
text (String.toUpper "Hello, Your Name!")
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 1.3: Multiple Elements
|
||||
|
||||
We need to use `Html.div` to group multiple elements:
|
||||
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
|
||||
import Html exposing (div, h1, p, text)
|
||||
|
||||
|
||||
main =
|
||||
div []
|
||||
[ h1 [] [ text "Welcome to Elm" ]
|
||||
, p [] [ text "This is a paragraph" ]
|
||||
]
|
||||
```
|
||||
|
||||
The pattern is: `element attributes children`
|
||||
- `div []` - a div with no attributes
|
||||
- `[ h1 ..., p ... ]` - list of children
|
||||
|
||||
Create a page with:
|
||||
1. A heading with your name
|
||||
2. A paragraph about why you're learning Elm
|
||||
3. Another paragraph about your programming background
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
module Main exposing (main)
|
||||
|
||||
import Html exposing (div, h1, p, text)
|
||||
|
||||
|
||||
main =
|
||||
div []
|
||||
[ h1 [] [ text "Hi, I'm Learning Elm!" ]
|
||||
, p [] [ text "I want to learn functional programming." ]
|
||||
, p [] [ text "I have experience with JavaScript." ]
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Elm is typed** - The compiler tells you types and catches errors early
|
||||
2. **No semicolons or braces** - Whitespace and indentation matter
|
||||
3. **Everything is an expression** - No statements, everything returns a value
|
||||
4. **String concatenation uses `++`** - Not `+` like JavaScript
|
||||
5. **HTML is just functions** - `div`, `h1`, `p` are all functions
|
||||
|
||||
## Common Mistakes from JavaScript Developers
|
||||
|
||||
| Mistake | Why It Happens | Elm Way |
|
||||
|---------|---------------|---------|
|
||||
| Using `+` for strings | JavaScript habit | Use `++` |
|
||||
| Forgetting type annotations | JS doesn't have them | Optional but recommended |
|
||||
| Using `return` | JS function habit | Last expression is returned |
|
||||
| Using `{}` for blocks | JS syntax | Use indentation |
|
||||
|
||||
## What's Next?
|
||||
|
||||
In [Lesson 2](02-basics.md), we'll dive deeper into Elm's type system and learn about:
|
||||
- Primitive types (Int, Float, String, Bool)
|
||||
- Type annotations
|
||||
- Records (like JavaScript objects, but better!)
|
||||
- Custom types
|
||||
|
||||
---
|
||||
|
||||
[Next: Lesson 2 - Basic Syntax & Types →](02-basics.md)
|
||||
485
lessons/02-basics.md
Normal file
485
lessons/02-basics.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# Lesson 2: Basic Syntax & Types
|
||||
|
||||
## Learning Goals
|
||||
|
||||
By the end of this lesson, you will:
|
||||
- Understand Elm's primitive types
|
||||
- Write type annotations
|
||||
- Work with records (Elm's "objects")
|
||||
- Create type aliases
|
||||
- Understand union types (a powerful feature JS doesn't have!)
|
||||
|
||||
## Primitive Types
|
||||
|
||||
### Numbers
|
||||
|
||||
```elm
|
||||
-- Integers
|
||||
age : Int
|
||||
age = 30
|
||||
|
||||
-- Floating point
|
||||
price : Float
|
||||
price = 19.99
|
||||
|
||||
-- The 'number' type works with both
|
||||
double : number -> number
|
||||
double n = n * 2
|
||||
|
||||
double 5 -- 10 : number
|
||||
double 5.5 -- 11.0 : Float
|
||||
```
|
||||
|
||||
**JavaScript comparison:**
|
||||
```javascript
|
||||
// JavaScript - just "number"
|
||||
const age = 30;
|
||||
const price = 19.99;
|
||||
```
|
||||
|
||||
### Strings
|
||||
|
||||
```elm
|
||||
name : String
|
||||
name = "Alice"
|
||||
|
||||
-- Multi-line strings use triple quotes
|
||||
poem : String
|
||||
poem = """
|
||||
Roses are red,
|
||||
Violets are blue,
|
||||
Elm has no nulls,
|
||||
And neither should you!
|
||||
"""
|
||||
|
||||
-- String operations
|
||||
String.length "hello" -- 5
|
||||
String.reverse "hello" -- "olleh"
|
||||
String.toUpper "hello" -- "HELLO"
|
||||
"Hello" ++ " World" -- "Hello World"
|
||||
```
|
||||
|
||||
**JavaScript comparison:**
|
||||
```javascript
|
||||
const name = "Alice";
|
||||
const poem = `
|
||||
Roses are red...
|
||||
`;
|
||||
"Hello" + " World" // Uses + not ++
|
||||
```
|
||||
|
||||
### Booleans
|
||||
|
||||
```elm
|
||||
isActive : Bool
|
||||
isActive = True
|
||||
|
||||
-- Note: True and False are capitalized!
|
||||
-- Comparison operators
|
||||
5 > 3 -- True
|
||||
5 == 5 -- True (equality uses ==, same as JS)
|
||||
5 /= 3 -- True (not equal uses /= not !==)
|
||||
|
||||
-- Boolean operators
|
||||
True && False -- False
|
||||
True || False -- True
|
||||
not True -- False
|
||||
```
|
||||
|
||||
**Key difference:** Not-equal is `/=` in Elm, not `!==` like JavaScript.
|
||||
|
||||
### Characters
|
||||
|
||||
```elm
|
||||
-- Single characters use single quotes
|
||||
letter : Char
|
||||
letter = 'A'
|
||||
|
||||
-- Strings use double quotes
|
||||
word : String
|
||||
word = "ABC"
|
||||
```
|
||||
|
||||
JavaScript doesn't have a separate character type.
|
||||
|
||||
## Type Annotations
|
||||
|
||||
Type annotations are **optional** but highly recommended:
|
||||
|
||||
```elm
|
||||
-- Without annotation (Elm infers the type)
|
||||
add a b = a + b
|
||||
|
||||
-- With annotation (clearer and self-documenting)
|
||||
add : Int -> Int -> Int
|
||||
add a b = a + b
|
||||
```
|
||||
|
||||
### Reading Type Annotations
|
||||
|
||||
```elm
|
||||
greet : String -> String
|
||||
-- ^input ^output
|
||||
greet name = "Hello, " ++ name
|
||||
|
||||
add : Int -> Int -> Int
|
||||
-- ^first ^second ^result
|
||||
add a b = a + b
|
||||
```
|
||||
|
||||
Think of `->` as "then returns". So `Int -> Int -> Int` reads as:
|
||||
"Takes an Int, then an Int, then returns an Int"
|
||||
|
||||
## Records: Elm's Objects
|
||||
|
||||
Records are like JavaScript objects, but **immutable** and **typed**.
|
||||
|
||||
```elm
|
||||
-- Defining a record
|
||||
person =
|
||||
{ name = "Alice"
|
||||
, age = 30
|
||||
, email = "alice@example.com"
|
||||
}
|
||||
|
||||
-- Accessing fields (dot notation, like JS)
|
||||
person.name -- "Alice"
|
||||
person.age -- 30
|
||||
|
||||
-- Or using accessor functions
|
||||
.name person -- "Alice"
|
||||
```
|
||||
|
||||
### Record Type Annotations
|
||||
|
||||
```elm
|
||||
-- Inline type
|
||||
alice : { name : String, age : Int }
|
||||
alice = { name = "Alice", age = 30 }
|
||||
|
||||
-- With type alias (preferred for reuse)
|
||||
type alias Person =
|
||||
{ name : String
|
||||
, age : Int
|
||||
, email : String
|
||||
}
|
||||
|
||||
bob : Person
|
||||
bob =
|
||||
{ name = "Bob"
|
||||
, age = 25
|
||||
, email = "bob@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Records
|
||||
|
||||
In Elm, you can't mutate records. Instead, you create a new one:
|
||||
|
||||
```elm
|
||||
-- JavaScript way (mutates)
|
||||
-- person.age = 31;
|
||||
|
||||
-- Elm way (creates new record)
|
||||
olderPerson = { person | age = 31 }
|
||||
|
||||
-- Update multiple fields
|
||||
updatedPerson =
|
||||
{ person
|
||||
| age = 31
|
||||
, email = "newemail@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
The original `person` is unchanged! This is **immutability**.
|
||||
|
||||
### Why Immutability Matters
|
||||
|
||||
```javascript
|
||||
// JavaScript - Mutation causes bugs
|
||||
const user = { name: "Alice", score: 100 };
|
||||
doSomething(user);
|
||||
console.log(user.score); // Who knows? doSomething might have changed it!
|
||||
```
|
||||
|
||||
```elm
|
||||
-- Elm - No surprises
|
||||
user = { name = "Alice", score = 100 }
|
||||
newUser = doSomething user
|
||||
user.score -- Still 100, guaranteed!
|
||||
newUser.score -- Whatever doSomething returned
|
||||
```
|
||||
|
||||
## Type Aliases
|
||||
|
||||
Type aliases give names to types:
|
||||
|
||||
```elm
|
||||
type alias Person =
|
||||
{ name : String
|
||||
, age : Int
|
||||
}
|
||||
|
||||
type alias Point =
|
||||
{ x : Float
|
||||
, y : Float
|
||||
}
|
||||
|
||||
-- Use them in annotations
|
||||
distance : Point -> Point -> Float
|
||||
distance p1 p2 =
|
||||
sqrt ((p2.x - p1.x)^2 + (p2.y - p1.y)^2)
|
||||
```
|
||||
|
||||
Type aliases also create **constructor functions**:
|
||||
|
||||
```elm
|
||||
type alias Person =
|
||||
{ name : String
|
||||
, age : Int
|
||||
}
|
||||
|
||||
-- This automatically creates:
|
||||
-- Person : String -> Int -> Person
|
||||
|
||||
alice = Person "Alice" 30
|
||||
-- Same as: { name = "Alice", age = 30 }
|
||||
```
|
||||
|
||||
## Custom Types (Union Types)
|
||||
|
||||
This is where Elm gets really powerful. Custom types let you define your own types with specific variants:
|
||||
|
||||
```elm
|
||||
type Color
|
||||
= Red
|
||||
| Green
|
||||
| Blue
|
||||
|
||||
type Status
|
||||
= Loading
|
||||
| Success String
|
||||
| Error String
|
||||
```
|
||||
|
||||
### Using Custom Types
|
||||
|
||||
```elm
|
||||
type Status
|
||||
= Loading
|
||||
| Success String
|
||||
| Error String
|
||||
|
||||
showStatus : Status -> String
|
||||
showStatus status =
|
||||
case status of
|
||||
Loading ->
|
||||
"Loading..."
|
||||
|
||||
Success message ->
|
||||
"Success: " ++ message
|
||||
|
||||
Error errorMsg ->
|
||||
"Error: " ++ errorMsg
|
||||
|
||||
showStatus Loading -- "Loading..."
|
||||
showStatus (Success "Done!") -- "Success: Done!"
|
||||
showStatus (Error "Not found") -- "Error: Not found"
|
||||
```
|
||||
|
||||
### JavaScript Comparison
|
||||
|
||||
```javascript
|
||||
// JavaScript - using strings (error-prone)
|
||||
const status = "loading";
|
||||
if (status === "loading") { ... }
|
||||
if (status === "laoding") { ... } // Typo! No error
|
||||
|
||||
// JavaScript - using objects
|
||||
const status = { type: "success", message: "Done" };
|
||||
```
|
||||
|
||||
```elm
|
||||
-- Elm - compiler catches typos
|
||||
case status of
|
||||
Laoding -> ... -- COMPILE ERROR: Laoding is not defined
|
||||
```
|
||||
|
||||
### Maybe: Handling Missing Values
|
||||
|
||||
Elm has no `null` or `undefined`. Instead, it uses the `Maybe` type:
|
||||
|
||||
```elm
|
||||
type Maybe a
|
||||
= Just a
|
||||
| Nothing
|
||||
|
||||
-- Example: Safe dictionary lookup
|
||||
Dict.get "name" myDict -- Returns Maybe String
|
||||
|
||||
-- You MUST handle both cases
|
||||
case Dict.get "name" myDict of
|
||||
Just value ->
|
||||
"Found: " ++ value
|
||||
|
||||
Nothing ->
|
||||
"Not found"
|
||||
```
|
||||
|
||||
**This eliminates null pointer exceptions entirely!**
|
||||
|
||||
## Exercise 2.1: Define a Record Type
|
||||
|
||||
Create a `type alias` for a `Book` with:
|
||||
- title (String)
|
||||
- author (String)
|
||||
- pages (Int)
|
||||
- isRead (Bool)
|
||||
|
||||
Then create two book records.
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
type alias Book =
|
||||
{ title : String
|
||||
, author : String
|
||||
, pages : Int
|
||||
, isRead : Bool
|
||||
}
|
||||
|
||||
book1 : Book
|
||||
book1 =
|
||||
{ title = "The Elm Guide"
|
||||
, author = "Evan Czaplicki"
|
||||
, pages = 150
|
||||
, isRead = True
|
||||
}
|
||||
|
||||
book2 : Book
|
||||
book2 = Book "Learn You a Haskell" "Miran Lipovaca" 400 False
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 2.2: Update a Record
|
||||
|
||||
Given this record:
|
||||
|
||||
```elm
|
||||
player =
|
||||
{ name = "Hero"
|
||||
, health = 100
|
||||
, score = 0
|
||||
}
|
||||
```
|
||||
|
||||
Create a new record where:
|
||||
1. health is reduced to 80
|
||||
2. score is increased to 50
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
updatedPlayer =
|
||||
{ player
|
||||
| health = 80
|
||||
, score = 50
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 2.3: Create a Custom Type
|
||||
|
||||
Create a `TrafficLight` type with Red, Yellow, and Green variants.
|
||||
Write a function `canGo : TrafficLight -> Bool` that returns True only for Green.
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
type TrafficLight
|
||||
= Red
|
||||
| Yellow
|
||||
| Green
|
||||
|
||||
|
||||
canGo : TrafficLight -> Bool
|
||||
canGo light =
|
||||
case light of
|
||||
Green ->
|
||||
True
|
||||
|
||||
Yellow ->
|
||||
False
|
||||
|
||||
Red ->
|
||||
False
|
||||
|
||||
-- Or more concisely:
|
||||
canGo2 : TrafficLight -> Bool
|
||||
canGo2 light =
|
||||
light == Green
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 2.4: Maybe Practice
|
||||
|
||||
Write a function that takes a `Maybe Int` and returns the value doubled, or 0 if Nothing:
|
||||
|
||||
```elm
|
||||
doubleOrZero : Maybe Int -> Int
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
doubleOrZero : Maybe Int -> Int
|
||||
doubleOrZero maybeNum =
|
||||
case maybeNum of
|
||||
Just n ->
|
||||
n * 2
|
||||
|
||||
Nothing ->
|
||||
0
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Elm has distinct Int and Float types** - Unlike JavaScript's single "number"
|
||||
2. **Type annotations are documentation** - They make code self-explanatory
|
||||
3. **Records are immutable** - Use update syntax `{ record | field = value }`
|
||||
4. **Type aliases** create reusable type names AND constructor functions
|
||||
5. **Custom types** replace string constants and are type-safe
|
||||
6. **Maybe replaces null** - Forces you to handle missing values explicitly
|
||||
|
||||
## JavaScript to Elm Cheatsheet
|
||||
|
||||
| JavaScript | Elm |
|
||||
|-----------|-----|
|
||||
| `{}` | Record `{ field = value }` |
|
||||
| `obj.field` | Same: `record.field` |
|
||||
| `{...obj, field: newVal}` | `{ record \| field = newVal }` |
|
||||
| `null` / `undefined` | `Maybe` type |
|
||||
| String constants | Custom types |
|
||||
| `===` | `==` |
|
||||
| `!==` | `/=` |
|
||||
|
||||
## What's Next?
|
||||
|
||||
In [Lesson 3](03-functions.md), we'll explore:
|
||||
- Pure functions
|
||||
- Higher-order functions
|
||||
- Currying and partial application
|
||||
- The pipe operator
|
||||
|
||||
---
|
||||
|
||||
[← Previous: Lesson 1](01-introduction.md) | [Next: Lesson 3 - Functions →](03-functions.md)
|
||||
498
lessons/03-functions.md
Normal file
498
lessons/03-functions.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Lesson 3: Functions
|
||||
|
||||
## Learning Goals
|
||||
|
||||
By the end of this lesson, you will:
|
||||
- Write pure functions
|
||||
- Understand currying and partial application
|
||||
- Use higher-order functions (map, filter, reduce)
|
||||
- Use the pipe operator for readable code
|
||||
- Understand function composition
|
||||
|
||||
## Defining Functions
|
||||
|
||||
### Basic Syntax
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// const greet = (name) => "Hello, " + name;
|
||||
|
||||
-- Elm
|
||||
greet : String -> String
|
||||
greet name =
|
||||
"Hello, " ++ name
|
||||
```
|
||||
|
||||
Key differences:
|
||||
- No parentheses around parameters
|
||||
- No `return` keyword
|
||||
- No curly braces
|
||||
- Last expression is automatically returned
|
||||
|
||||
### Multiple Parameters
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// const add = (a, b) => a + b;
|
||||
|
||||
-- Elm
|
||||
add : Int -> Int -> Int
|
||||
add a b =
|
||||
a + b
|
||||
|
||||
-- Using it
|
||||
add 5 3 -- 8 (no parentheses or commas!)
|
||||
```
|
||||
|
||||
### If-Then-Else
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// const abs = x => x < 0 ? -x : x;
|
||||
|
||||
-- Elm
|
||||
abs : Int -> Int
|
||||
abs x =
|
||||
if x < 0 then
|
||||
-x
|
||||
else
|
||||
x
|
||||
```
|
||||
|
||||
**Important:** In Elm, `if` is an **expression** that returns a value, not a statement. Both branches must return the same type.
|
||||
|
||||
```elm
|
||||
-- This won't compile:
|
||||
invalid x =
|
||||
if x > 0 then
|
||||
"positive"
|
||||
-- Missing else! What would it return?
|
||||
```
|
||||
|
||||
## Pure Functions
|
||||
|
||||
In Elm, ALL functions are **pure**:
|
||||
|
||||
1. **Same input → Same output** (always)
|
||||
2. **No side effects** (no I/O, no mutation, no randomness)
|
||||
|
||||
```javascript
|
||||
// JavaScript - Impure function
|
||||
let counter = 0;
|
||||
function increment() {
|
||||
counter += 1; // Side effect: modifies external state
|
||||
return counter;
|
||||
}
|
||||
increment(); // 1
|
||||
increment(); // 2 - Different result!
|
||||
```
|
||||
|
||||
```elm
|
||||
-- Elm - Pure function
|
||||
increment : Int -> Int
|
||||
increment counter =
|
||||
counter + 1
|
||||
|
||||
increment 0 -- 1
|
||||
increment 0 -- 1 (always!)
|
||||
```
|
||||
|
||||
### Benefits of Pure Functions
|
||||
|
||||
1. **Testable** - No mocks needed, just input/output
|
||||
2. **Cacheable** - Same input = same output, so cache results
|
||||
3. **Predictable** - No hidden state changes
|
||||
4. **Parallelizable** - Safe to run simultaneously
|
||||
|
||||
## Currying and Partial Application
|
||||
|
||||
### What is Currying?
|
||||
|
||||
In Elm, every function takes exactly one argument. A function with "multiple arguments" is actually a chain of functions.
|
||||
|
||||
```elm
|
||||
add : Int -> Int -> Int
|
||||
add a b = a + b
|
||||
|
||||
-- This is actually:
|
||||
add : Int -> (Int -> Int)
|
||||
-- A function that takes an Int and returns a function that takes an Int
|
||||
```
|
||||
|
||||
### Partial Application
|
||||
|
||||
You can apply some arguments now and the rest later:
|
||||
|
||||
```elm
|
||||
add : Int -> Int -> Int
|
||||
add a b = a + b
|
||||
|
||||
addFive : Int -> Int
|
||||
addFive = add 5 -- Partially applied!
|
||||
|
||||
addFive 3 -- 8
|
||||
addFive 10 -- 15
|
||||
```
|
||||
|
||||
**JavaScript equivalent:**
|
||||
```javascript
|
||||
const add = a => b => a + b;
|
||||
const addFive = add(5);
|
||||
addFive(3); // 8
|
||||
```
|
||||
|
||||
### Practical Example
|
||||
|
||||
```elm
|
||||
-- A formatting function
|
||||
format : String -> String -> String
|
||||
format prefix text =
|
||||
prefix ++ ": " ++ text
|
||||
|
||||
-- Create specialized formatters
|
||||
formatError : String -> String
|
||||
formatError = format "ERROR"
|
||||
|
||||
formatWarning : String -> String
|
||||
formatWarning = format "WARNING"
|
||||
|
||||
formatError "File not found" -- "ERROR: File not found"
|
||||
formatWarning "Low memory" -- "WARNING: Low memory"
|
||||
```
|
||||
|
||||
## Anonymous Functions (Lambdas)
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// const double = x => x * 2;
|
||||
// numbers.map(x => x * 2);
|
||||
|
||||
-- Elm
|
||||
double = \x -> x * 2
|
||||
List.map (\x -> x * 2) numbers
|
||||
```
|
||||
|
||||
The `\` represents the Greek letter lambda (λ).
|
||||
|
||||
### Multiple Parameters
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// const add = (a, b) => a + b;
|
||||
|
||||
-- Elm
|
||||
add = \a b -> a + b
|
||||
-- Or equivalently
|
||||
add = \a -> \b -> a + b
|
||||
```
|
||||
|
||||
## Higher-Order Functions
|
||||
|
||||
Higher-order functions take or return other functions.
|
||||
|
||||
### List.map (like Array.map)
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// [1, 2, 3].map(x => x * 2)
|
||||
|
||||
-- Elm
|
||||
List.map (\x -> x * 2) [1, 2, 3] -- [2, 4, 6]
|
||||
|
||||
-- Or with a named function
|
||||
double x = x * 2
|
||||
List.map double [1, 2, 3] -- [2, 4, 6]
|
||||
```
|
||||
|
||||
### List.filter (like Array.filter)
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// [1, 2, 3, 4, 5].filter(x => x > 3)
|
||||
|
||||
-- Elm
|
||||
List.filter (\x -> x > 3) [1, 2, 3, 4, 5] -- [4, 5]
|
||||
```
|
||||
|
||||
### List.foldl / List.foldr (like Array.reduce)
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// [1, 2, 3].reduce((sum, x) => sum + x, 0)
|
||||
|
||||
-- Elm
|
||||
List.foldl (\x sum -> sum + x) 0 [1, 2, 3] -- 6
|
||||
|
||||
-- Or using the (+) operator as a function
|
||||
List.foldl (+) 0 [1, 2, 3] -- 6
|
||||
```
|
||||
|
||||
Note: Arguments are in different order than JavaScript!
|
||||
- `foldl` goes left-to-right
|
||||
- `foldr` goes right-to-left
|
||||
|
||||
### Other Useful Functions
|
||||
|
||||
```elm
|
||||
List.head [1, 2, 3] -- Just 1 (returns Maybe!)
|
||||
List.tail [1, 2, 3] -- Just [2, 3]
|
||||
List.take 2 [1, 2, 3, 4] -- [1, 2]
|
||||
List.drop 2 [1, 2, 3, 4] -- [3, 4]
|
||||
List.reverse [1, 2, 3] -- [3, 2, 1]
|
||||
List.sort [3, 1, 2] -- [1, 2, 3]
|
||||
List.member 2 [1, 2, 3] -- True
|
||||
List.length [1, 2, 3] -- 3
|
||||
```
|
||||
|
||||
## The Pipe Operator |>
|
||||
|
||||
The pipe operator makes chains of functions readable:
|
||||
|
||||
```elm
|
||||
-- Without pipes (hard to read, inside-out)
|
||||
String.toUpper (String.trim (String.reverse " hello "))
|
||||
|
||||
-- With pipes (clear data flow)
|
||||
" hello "
|
||||
|> String.reverse
|
||||
|> String.trim
|
||||
|> String.toUpper
|
||||
-- Result: "OLLEH"
|
||||
```
|
||||
|
||||
**How it works:** `x |> f` is the same as `f x`
|
||||
|
||||
### JavaScript Comparison
|
||||
|
||||
```javascript
|
||||
// JavaScript method chaining (only works with methods)
|
||||
" hello ".split("").reverse().join("").trim().toUpperCase()
|
||||
|
||||
// JavaScript pipe (proposed, not standard)
|
||||
" hello " |> reverse |> trim |> toUpperCase
|
||||
```
|
||||
|
||||
### Complex Example
|
||||
|
||||
```elm
|
||||
-- Get the names of users over 21, sorted
|
||||
users
|
||||
|> List.filter (\user -> user.age > 21)
|
||||
|> List.map .name
|
||||
|> List.sort
|
||||
```
|
||||
|
||||
## Function Composition
|
||||
|
||||
Combine functions into new functions:
|
||||
|
||||
```elm
|
||||
-- The >> operator (left to right)
|
||||
addOne = (+) 1
|
||||
double = (*) 2
|
||||
|
||||
addOneThenDouble : Int -> Int
|
||||
addOneThenDouble = addOne >> double
|
||||
|
||||
addOneThenDouble 5 -- 12 (5+1=6, 6*2=12)
|
||||
|
||||
-- The << operator (right to left)
|
||||
doubleThenAddOne : Int -> Int
|
||||
doubleThenAddOne = addOne << double
|
||||
|
||||
doubleThenAddOne 5 -- 11 (5*2=10, 10+1=11)
|
||||
```
|
||||
|
||||
### Pipe vs Composition
|
||||
|
||||
```elm
|
||||
-- Pipe: Apply to a specific value
|
||||
5 |> addOne |> double -- 12
|
||||
|
||||
-- Composition: Create a new function
|
||||
addOneThenDouble = addOne >> double
|
||||
addOneThenDouble 5 -- 12
|
||||
```
|
||||
|
||||
## Let Expressions
|
||||
|
||||
Use `let...in` for local bindings:
|
||||
|
||||
```elm
|
||||
-- JavaScript
|
||||
// function circleArea(radius) {
|
||||
// const pi = 3.14159;
|
||||
// const squared = radius * radius;
|
||||
// return pi * squared;
|
||||
// }
|
||||
|
||||
-- Elm
|
||||
circleArea : Float -> Float
|
||||
circleArea radius =
|
||||
let
|
||||
pi = 3.14159
|
||||
squared = radius * radius
|
||||
in
|
||||
pi * squared
|
||||
```
|
||||
|
||||
You can also define helper functions:
|
||||
|
||||
```elm
|
||||
pythagoras : Float -> Float -> Float
|
||||
pythagoras a b =
|
||||
let
|
||||
square x = x * x
|
||||
in
|
||||
sqrt (square a + square b)
|
||||
```
|
||||
|
||||
## Exercise 3.1: Basic Functions
|
||||
|
||||
Write these functions:
|
||||
|
||||
1. `isEven : Int -> Bool` - Returns True if number is even
|
||||
2. `exclaim : String -> String` - Adds "!" to the end of a string
|
||||
3. `greet : String -> String -> String` - Takes a greeting and a name
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
isEven : Int -> Bool
|
||||
isEven n =
|
||||
modBy 2 n == 0
|
||||
|
||||
|
||||
exclaim : String -> String
|
||||
exclaim text =
|
||||
text ++ "!"
|
||||
|
||||
|
||||
greet : String -> String -> String
|
||||
greet greeting name =
|
||||
greeting ++ ", " ++ name ++ "!"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 3.2: Higher-Order Functions
|
||||
|
||||
Given this list:
|
||||
```elm
|
||||
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
```
|
||||
|
||||
Use List functions to:
|
||||
1. Get only the odd numbers
|
||||
2. Square each number
|
||||
3. Get the sum of all numbers
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
-- 1. Odd numbers
|
||||
List.filter (\n -> modBy 2 n /= 0) numbers
|
||||
-- [1, 3, 5, 7, 9]
|
||||
|
||||
-- 2. Squared
|
||||
List.map (\n -> n * n) numbers
|
||||
-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
|
||||
|
||||
-- 3. Sum
|
||||
List.foldl (+) 0 numbers
|
||||
-- Or: List.sum numbers
|
||||
-- 55
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 3.3: Pipe Operator
|
||||
|
||||
Rewrite this using the pipe operator:
|
||||
|
||||
```elm
|
||||
String.fromInt (List.length (List.filter isEven (List.range 1 100)))
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
List.range 1 100
|
||||
|> List.filter isEven
|
||||
|> List.length
|
||||
|> String.fromInt
|
||||
-- "50"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 3.4: Partial Application
|
||||
|
||||
Create a `greetUser` function by partially applying this:
|
||||
|
||||
```elm
|
||||
greet : String -> String -> String
|
||||
greet greeting name =
|
||||
greeting ++ ", " ++ name ++ "!"
|
||||
```
|
||||
|
||||
So that `greetUser "Alice"` returns `"Hello, Alice!"`
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
greetUser : String -> String
|
||||
greetUser = greet "Hello"
|
||||
|
||||
greetUser "Alice" -- "Hello, Alice!"
|
||||
greetUser "Bob" -- "Hello, Bob!"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **All Elm functions are pure** - Same input always gives same output
|
||||
2. **All functions are curried** - Multi-arg functions are chains of single-arg functions
|
||||
3. **Partial application** lets you create specialized functions
|
||||
4. **The pipe operator `|>`** makes data transformation chains readable
|
||||
5. **Higher-order functions** like `map`, `filter`, `foldl` are your primary tools
|
||||
6. **`let...in`** provides local bindings within functions
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```elm
|
||||
-- Transform a list
|
||||
items |> List.map transform
|
||||
|
||||
-- Filter and transform
|
||||
items
|
||||
|> List.filter condition
|
||||
|> List.map transform
|
||||
|
||||
-- Find something
|
||||
items
|
||||
|> List.filter condition
|
||||
|> List.head
|
||||
|
||||
-- Aggregate
|
||||
items
|
||||
|> List.foldl combiner initial
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
In [Lesson 4](04-tea.md), we'll learn The Elm Architecture (TEA):
|
||||
- Model: Your application state
|
||||
- View: Turning state into HTML
|
||||
- Update: Handling messages and updating state
|
||||
|
||||
This is the heart of Elm applications and inspired Redux!
|
||||
|
||||
---
|
||||
|
||||
[← Previous: Lesson 2](02-basics.md) | [Next: Lesson 4 - The Elm Architecture →](04-tea.md)
|
||||
729
lessons/04-tea.md
Normal file
729
lessons/04-tea.md
Normal file
@@ -0,0 +1,729 @@
|
||||
# Lesson 4: The Elm Architecture (TEA)
|
||||
|
||||
## Learning Goals
|
||||
|
||||
By the end of this lesson, you will:
|
||||
- Understand the Model-View-Update pattern
|
||||
- Handle user input events
|
||||
- Build interactive applications
|
||||
- Understand how Elm inspired Redux
|
||||
|
||||
## What is The Elm Architecture?
|
||||
|
||||
The Elm Architecture (TEA) is a pattern for building web applications. It has three parts:
|
||||
|
||||
1. **Model** - Your application's state (the data)
|
||||
2. **View** - A function that turns the model into HTML
|
||||
3. **Update** - A function that updates the model based on messages
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ │
|
||||
│ User clicks button │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Message │ │
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Update │───▶│ Model │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ View │ │
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ HTML │───┘
|
||||
│ └──────────────┘
|
||||
│
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Redux Connection
|
||||
|
||||
If you've used Redux, you already know TEA!
|
||||
|
||||
| Elm | Redux |
|
||||
|-----|-------|
|
||||
| Model | State/Store |
|
||||
| Msg | Action |
|
||||
| Update | Reducer |
|
||||
| View | React Component |
|
||||
|
||||
## Your First Interactive App: Counter
|
||||
|
||||
Let's build a counter that you can increment and decrement.
|
||||
|
||||
Create a new file `src/Counter.elm`:
|
||||
|
||||
```elm
|
||||
module Counter exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html, button, div, text)
|
||||
import Html.Events exposing (onClick)
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
type alias Model =
|
||||
{ count : Int
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ count = 0
|
||||
}
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
type Msg
|
||||
= Increment
|
||||
| Decrement
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
Increment ->
|
||||
{ model | count = model.count + 1 }
|
||||
|
||||
Decrement ->
|
||||
{ model | count = model.count - 1 }
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ button [ onClick Decrement ] [ text "-" ]
|
||||
, div [] [ text (String.fromInt model.count) ]
|
||||
, button [ onClick Increment ] [ text "+" ]
|
||||
]
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
Run it with:
|
||||
```bash
|
||||
elm reactor
|
||||
```
|
||||
Then open http://localhost:8000/src/Counter.elm
|
||||
|
||||
### Breaking It Down
|
||||
|
||||
#### 1. The Model
|
||||
|
||||
```elm
|
||||
type alias Model =
|
||||
{ count : Int
|
||||
}
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ count = 0
|
||||
}
|
||||
```
|
||||
|
||||
The model is just a record holding your state. `init` is the initial state.
|
||||
|
||||
**JavaScript/Redux equivalent:**
|
||||
```javascript
|
||||
const initialState = { count: 0 };
|
||||
```
|
||||
|
||||
#### 2. Messages (Msg)
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= Increment
|
||||
| Decrement
|
||||
```
|
||||
|
||||
Messages describe things that can happen. They're like Redux action types, but type-safe.
|
||||
|
||||
**JavaScript/Redux equivalent:**
|
||||
```javascript
|
||||
const INCREMENT = 'INCREMENT';
|
||||
const DECREMENT = 'DECREMENT';
|
||||
```
|
||||
|
||||
#### 3. The Update Function
|
||||
|
||||
```elm
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
Increment ->
|
||||
{ model | count = model.count + 1 }
|
||||
|
||||
Decrement ->
|
||||
{ model | count = model.count - 1 }
|
||||
```
|
||||
|
||||
Takes a message and the current model, returns a new model. This is a **pure function** - no side effects!
|
||||
|
||||
**JavaScript/Redux equivalent:**
|
||||
```javascript
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case INCREMENT:
|
||||
return { ...state, count: state.count + 1 };
|
||||
case DECREMENT:
|
||||
return { ...state, count: state.count - 1 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. The View Function
|
||||
|
||||
```elm
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ button [ onClick Decrement ] [ text "-" ]
|
||||
, div [] [ text (String.fromInt model.count) ]
|
||||
, button [ onClick Increment ] [ text "+" ]
|
||||
]
|
||||
```
|
||||
|
||||
A pure function that takes the model and returns HTML. The `Html Msg` type means "HTML that can produce Msg values."
|
||||
|
||||
**React equivalent:**
|
||||
```jsx
|
||||
function Counter({ count, dispatch }) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
|
||||
<div>{count}</div>
|
||||
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Wiring It Together
|
||||
|
||||
```elm
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
`Browser.sandbox` connects everything. Elm handles:
|
||||
- Calling `view` whenever the model changes
|
||||
- Routing user events to `update`
|
||||
- Re-rendering efficiently (virtual DOM)
|
||||
|
||||
## HTML in Elm
|
||||
|
||||
Every HTML element follows this pattern:
|
||||
```elm
|
||||
element attributes children
|
||||
```
|
||||
|
||||
```elm
|
||||
-- <div class="container">Hello</div>
|
||||
div [ class "container" ] [ text "Hello" ]
|
||||
|
||||
-- <button id="submit" disabled>Submit</button>
|
||||
button [ id "submit", disabled True ] [ text "Submit" ]
|
||||
|
||||
-- <input type="text" value="hello" />
|
||||
input [ type_ "text", value "hello" ] []
|
||||
```
|
||||
|
||||
Note: Some attributes like `type` use underscores (`type_`) because they conflict with Elm keywords.
|
||||
|
||||
### Common Attributes
|
||||
|
||||
```elm
|
||||
import Html.Attributes exposing (..)
|
||||
|
||||
-- Styling
|
||||
class "my-class"
|
||||
style "color" "red"
|
||||
id "my-id"
|
||||
|
||||
-- Form inputs
|
||||
value "text"
|
||||
placeholder "Enter text..."
|
||||
disabled True
|
||||
checked True
|
||||
|
||||
-- Links and images
|
||||
href "https://elm-lang.org"
|
||||
src "image.png"
|
||||
alt "Description"
|
||||
```
|
||||
|
||||
### Common Events
|
||||
|
||||
```elm
|
||||
import Html.Events exposing (..)
|
||||
|
||||
onClick Msg -- Button clicks
|
||||
onInput (String -> Msg) -- Text input
|
||||
onSubmit Msg -- Form submission
|
||||
onMouseEnter Msg -- Mouse enter
|
||||
onMouseLeave Msg -- Mouse leave
|
||||
```
|
||||
|
||||
## Example 2: Text Input
|
||||
|
||||
```elm
|
||||
module TextInput exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html, div, input, text)
|
||||
import Html.Attributes exposing (placeholder, value)
|
||||
import Html.Events exposing (onInput)
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
type alias Model =
|
||||
{ content : String
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ content = ""
|
||||
}
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
type Msg
|
||||
= Change String
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
Change newContent ->
|
||||
{ model | content = newContent }
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ input
|
||||
[ placeholder "Type something..."
|
||||
, value model.content
|
||||
, onInput Change
|
||||
]
|
||||
[]
|
||||
, div [] [ text ("You typed: " ++ model.content) ]
|
||||
, div [] [ text ("Length: " ++ String.fromInt (String.length model.content)) ]
|
||||
]
|
||||
|
||||
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
### Key Point: Messages with Data
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= Change String -- This message carries a String!
|
||||
|
||||
-- onInput sends the input's value with the message
|
||||
onInput Change -- When input changes, send: Change "new value"
|
||||
|
||||
-- In update, extract the data
|
||||
Change newContent ->
|
||||
{ model | content = newContent }
|
||||
```
|
||||
|
||||
## Example 3: Todo List
|
||||
|
||||
A more complex example with a list of items:
|
||||
|
||||
```elm
|
||||
module TodoList exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html, button, div, input, li, text, ul)
|
||||
import Html.Attributes exposing (placeholder, value)
|
||||
import Html.Events exposing (onClick, onInput)
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
type alias Model =
|
||||
{ todos : List String
|
||||
, newTodo : String
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ todos = []
|
||||
, newTodo = ""
|
||||
}
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
type Msg
|
||||
= UpdateNewTodo String
|
||||
| AddTodo
|
||||
| RemoveTodo Int
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateNewTodo text ->
|
||||
{ model | newTodo = text }
|
||||
|
||||
AddTodo ->
|
||||
if String.isEmpty model.newTodo then
|
||||
model
|
||||
else
|
||||
{ model
|
||||
| todos = model.todos ++ [ model.newTodo ]
|
||||
, newTodo = ""
|
||||
}
|
||||
|
||||
RemoveTodo index ->
|
||||
{ model
|
||||
| todos = removeAt index model.todos
|
||||
}
|
||||
|
||||
|
||||
removeAt : Int -> List a -> List a
|
||||
removeAt index list =
|
||||
List.take index list ++ List.drop (index + 1) list
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ div []
|
||||
[ input
|
||||
[ placeholder "Add a todo..."
|
||||
, value model.newTodo
|
||||
, onInput UpdateNewTodo
|
||||
]
|
||||
[]
|
||||
, button [ onClick AddTodo ] [ text "Add" ]
|
||||
]
|
||||
, ul [] (List.indexedMap viewTodo model.todos)
|
||||
]
|
||||
|
||||
|
||||
viewTodo : Int -> String -> Html Msg
|
||||
viewTodo index todo =
|
||||
li []
|
||||
[ text todo
|
||||
, button [ onClick (RemoveTodo index) ] [ text "x" ]
|
||||
]
|
||||
|
||||
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **List.indexedMap** - Like map, but gives you the index too
|
||||
2. **Messages with multiple types of data** - `RemoveTodo Int` carries which item to remove
|
||||
3. **Helper functions** - `removeAt` and `viewTodo` keep code organized
|
||||
|
||||
## Exercise 4.1: Add Features to Counter
|
||||
|
||||
Enhance the counter with:
|
||||
1. A "Reset" button that sets count to 0
|
||||
2. A "Double" button that doubles the count
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= Increment
|
||||
| Decrement
|
||||
| Reset
|
||||
| Double
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
Increment ->
|
||||
{ model | count = model.count + 1 }
|
||||
|
||||
Decrement ->
|
||||
{ model | count = model.count - 1 }
|
||||
|
||||
Reset ->
|
||||
{ model | count = 0 }
|
||||
|
||||
Double ->
|
||||
{ model | count = model.count * 2 }
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ button [ onClick Decrement ] [ text "-" ]
|
||||
, div [] [ text (String.fromInt model.count) ]
|
||||
, button [ onClick Increment ] [ text "+" ]
|
||||
, button [ onClick Reset ] [ text "Reset" ]
|
||||
, button [ onClick Double ] [ text "Double" ]
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 4.2: Temperature Converter
|
||||
|
||||
Build a Celsius to Fahrenheit converter:
|
||||
- Input field for Celsius
|
||||
- Display the Fahrenheit equivalent
|
||||
- Formula: F = C × 9/5 + 32
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
module TempConverter exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html, div, input, text)
|
||||
import Html.Attributes exposing (placeholder, value)
|
||||
import Html.Events exposing (onInput)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ celsius : String
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ celsius = ""
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateCelsius String
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateCelsius value ->
|
||||
{ model | celsius = value }
|
||||
|
||||
|
||||
celsiusToFahrenheit : Float -> Float
|
||||
celsiusToFahrenheit c =
|
||||
c * 9 / 5 + 32
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
let
|
||||
fahrenheit =
|
||||
case String.toFloat model.celsius of
|
||||
Just c ->
|
||||
String.fromFloat (celsiusToFahrenheit c) ++ "°F"
|
||||
|
||||
Nothing ->
|
||||
"Enter a valid number"
|
||||
in
|
||||
div []
|
||||
[ input
|
||||
[ placeholder "Celsius"
|
||||
, value model.celsius
|
||||
, onInput UpdateCelsius
|
||||
]
|
||||
[]
|
||||
, text " °C = "
|
||||
, text fahrenheit
|
||||
]
|
||||
|
||||
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 4.3: Signup Form
|
||||
|
||||
Build a signup form with:
|
||||
- Name field
|
||||
- Email field
|
||||
- Password field
|
||||
- "Sign Up" button
|
||||
- Display "Welcome, {name}!" after clicking Sign Up
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
module Signup exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html, button, div, input, text)
|
||||
import Html.Attributes exposing (placeholder, type_, value)
|
||||
import Html.Events exposing (onClick, onInput)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ name : String
|
||||
, email : String
|
||||
, password : String
|
||||
, submitted : Bool
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ name = ""
|
||||
, email = ""
|
||||
, password = ""
|
||||
, submitted = False
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateName String
|
||||
| UpdateEmail String
|
||||
| UpdatePassword String
|
||||
| Submit
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateName value ->
|
||||
{ model | name = value }
|
||||
|
||||
UpdateEmail value ->
|
||||
{ model | email = value }
|
||||
|
||||
UpdatePassword value ->
|
||||
{ model | password = value }
|
||||
|
||||
Submit ->
|
||||
{ model | submitted = True }
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
if model.submitted then
|
||||
div [] [ text ("Welcome, " ++ model.name ++ "!") ]
|
||||
else
|
||||
div []
|
||||
[ div []
|
||||
[ input
|
||||
[ placeholder "Name"
|
||||
, value model.name
|
||||
, onInput UpdateName
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div []
|
||||
[ input
|
||||
[ placeholder "Email"
|
||||
, type_ "email"
|
||||
, value model.email
|
||||
, onInput UpdateEmail
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div []
|
||||
[ input
|
||||
[ placeholder "Password"
|
||||
, type_ "password"
|
||||
, value model.password
|
||||
, onInput UpdatePassword
|
||||
]
|
||||
[]
|
||||
]
|
||||
, button [ onClick Submit ] [ text "Sign Up" ]
|
||||
]
|
||||
|
||||
|
||||
main =
|
||||
Browser.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **TEA has three parts:** Model, View, Update
|
||||
2. **Model** holds all your application state
|
||||
3. **View** is a pure function: Model → HTML
|
||||
4. **Update** is a pure function: Msg → Model → Model
|
||||
5. **Messages** describe events and can carry data
|
||||
6. **Browser.sandbox** wires everything together
|
||||
7. **HTML is just functions** with attributes and children
|
||||
|
||||
## The Elm Architecture Benefits
|
||||
|
||||
1. **No unexpected state changes** - All updates go through update
|
||||
2. **Time-travel debugging** - Elm debugger lets you replay messages
|
||||
3. **Easy testing** - Pure functions with no side effects
|
||||
4. **Predictable** - Given a model and message, you know the result
|
||||
|
||||
## What's Next?
|
||||
|
||||
In [Lesson 5](05-lists-maybe.md), we'll dive deeper into:
|
||||
- Working with Lists
|
||||
- The Maybe type for handling missing values
|
||||
- Pattern matching techniques
|
||||
- Common List operations
|
||||
|
||||
---
|
||||
|
||||
[← Previous: Lesson 3](03-functions.md) | [Next: Lesson 5 - Lists & Maybe →](05-lists-maybe.md)
|
||||
533
lessons/05-lists-maybe.md
Normal file
533
lessons/05-lists-maybe.md
Normal file
@@ -0,0 +1,533 @@
|
||||
# Lesson 5: Lists & Maybe
|
||||
|
||||
## Learning Goals
|
||||
|
||||
By the end of this lesson, you will:
|
||||
- Master List operations in Elm
|
||||
- Understand the Maybe type deeply
|
||||
- Handle missing values safely
|
||||
- Use pattern matching effectively
|
||||
- Work with Result for error handling
|
||||
|
||||
## Lists in Elm
|
||||
|
||||
Lists in Elm are **linked lists** (not arrays like JavaScript).
|
||||
|
||||
```elm
|
||||
-- Creating lists
|
||||
numbers : List Int
|
||||
numbers = [1, 2, 3, 4, 5]
|
||||
|
||||
strings : List String
|
||||
strings = ["apple", "banana", "cherry"]
|
||||
|
||||
empty : List a
|
||||
empty = []
|
||||
|
||||
-- Using cons operator (::) to prepend
|
||||
moreNumbers = 0 :: numbers -- [0, 1, 2, 3, 4, 5]
|
||||
```
|
||||
|
||||
### JavaScript Comparison
|
||||
|
||||
| JavaScript | Elm |
|
||||
|-----------|-----|
|
||||
| `[1, 2, 3]` | `[1, 2, 3]` |
|
||||
| `arr.push(4)` | Creates new list: `arr ++ [4]` |
|
||||
| `arr.unshift(0)` | `0 :: arr` (prepend) |
|
||||
| `arr[0]` | `List.head arr` (returns Maybe!) |
|
||||
| `arr.length` | `List.length arr` |
|
||||
|
||||
### Important: Lists Are Homogeneous
|
||||
|
||||
```elm
|
||||
-- This works
|
||||
numbers = [1, 2, 3]
|
||||
|
||||
-- This does NOT compile
|
||||
mixed = [1, "hello", True] -- ERROR: all elements must be same type
|
||||
```
|
||||
|
||||
JavaScript allows mixed arrays, Elm doesn't. This catches bugs early!
|
||||
|
||||
## Essential List Functions
|
||||
|
||||
### Creating Lists
|
||||
|
||||
```elm
|
||||
List.range 1 5 -- [1, 2, 3, 4, 5]
|
||||
List.repeat 3 "hi" -- ["hi", "hi", "hi"]
|
||||
List.singleton 42 -- [42]
|
||||
```
|
||||
|
||||
### Transforming Lists
|
||||
|
||||
```elm
|
||||
List.map (\x -> x * 2) [1, 2, 3]
|
||||
-- [2, 4, 6]
|
||||
|
||||
List.map String.toUpper ["a", "b", "c"]
|
||||
-- ["A", "B", "C"]
|
||||
|
||||
-- With index
|
||||
List.indexedMap (\i x -> String.fromInt i ++ ": " ++ x) ["a", "b"]
|
||||
-- ["0: a", "1: b"]
|
||||
```
|
||||
|
||||
### Filtering
|
||||
|
||||
```elm
|
||||
List.filter (\x -> x > 2) [1, 2, 3, 4, 5]
|
||||
-- [3, 4, 5]
|
||||
|
||||
List.filter String.isEmpty ["", "hello", "", "world"]
|
||||
-- ["", ""]
|
||||
|
||||
-- Partition: split into two lists
|
||||
List.partition (\x -> x > 2) [1, 2, 3, 4, 5]
|
||||
-- ([3, 4, 5], [1, 2])
|
||||
```
|
||||
|
||||
### Combining Lists
|
||||
|
||||
```elm
|
||||
[1, 2] ++ [3, 4] -- [1, 2, 3, 4]
|
||||
List.concat [[1,2], [3,4]] -- [1, 2, 3, 4]
|
||||
List.intersperse 0 [1,2,3] -- [1, 0, 2, 0, 3]
|
||||
```
|
||||
|
||||
### Reducing (Folding)
|
||||
|
||||
```elm
|
||||
-- Sum
|
||||
List.foldl (+) 0 [1, 2, 3, 4]
|
||||
-- 10
|
||||
|
||||
-- Product
|
||||
List.foldl (*) 1 [1, 2, 3, 4]
|
||||
-- 24
|
||||
|
||||
-- Build a string
|
||||
List.foldl (\item acc -> acc ++ item) "" ["a", "b", "c"]
|
||||
-- "abc"
|
||||
|
||||
-- Convenience functions
|
||||
List.sum [1, 2, 3, 4] -- 10
|
||||
List.product [1, 2, 3, 4] -- 24
|
||||
List.maximum [1, 5, 3] -- Just 5 (returns Maybe!)
|
||||
List.minimum [1, 5, 3] -- Just 1
|
||||
```
|
||||
|
||||
### Accessing Elements
|
||||
|
||||
```elm
|
||||
List.head [1, 2, 3] -- Just 1
|
||||
List.tail [1, 2, 3] -- Just [2, 3]
|
||||
List.head [] -- Nothing
|
||||
List.take 2 [1, 2, 3, 4] -- [1, 2]
|
||||
List.drop 2 [1, 2, 3, 4] -- [3, 4]
|
||||
```
|
||||
|
||||
Notice `head` and `tail` return `Maybe`! This is Elm protecting you.
|
||||
|
||||
## The Maybe Type
|
||||
|
||||
`Maybe` is Elm's solution to null/undefined. It forces you to handle missing values.
|
||||
|
||||
```elm
|
||||
type Maybe a
|
||||
= Just a
|
||||
| Nothing
|
||||
```
|
||||
|
||||
### Why Maybe?
|
||||
|
||||
```javascript
|
||||
// JavaScript - Runtime crash waiting to happen
|
||||
function getFirst(arr) {
|
||||
return arr[0]; // undefined if empty!
|
||||
}
|
||||
const first = getFirst([]);
|
||||
console.log(first.toUpperCase()); // CRASH!
|
||||
```
|
||||
|
||||
```elm
|
||||
-- Elm - Must handle both cases
|
||||
getFirst : List a -> Maybe a
|
||||
getFirst list =
|
||||
List.head list
|
||||
|
||||
case getFirst [] of
|
||||
Just value ->
|
||||
String.toUpper value -- Safe!
|
||||
|
||||
Nothing ->
|
||||
"No value" -- Must handle this case
|
||||
```
|
||||
|
||||
### Working with Maybe
|
||||
|
||||
#### Pattern Matching (Most Common)
|
||||
|
||||
```elm
|
||||
showResult : Maybe Int -> String
|
||||
showResult maybeNum =
|
||||
case maybeNum of
|
||||
Just n ->
|
||||
"The number is " ++ String.fromInt n
|
||||
|
||||
Nothing ->
|
||||
"No number found"
|
||||
```
|
||||
|
||||
#### Maybe.withDefault
|
||||
|
||||
```elm
|
||||
-- Provide a default value
|
||||
Maybe.withDefault 0 (Just 5) -- 5
|
||||
Maybe.withDefault 0 Nothing -- 0
|
||||
|
||||
-- Useful pattern
|
||||
name = Maybe.withDefault "Anonymous" maybeName
|
||||
```
|
||||
|
||||
#### Maybe.map
|
||||
|
||||
Transform the value inside if it exists:
|
||||
|
||||
```elm
|
||||
Maybe.map (\x -> x * 2) (Just 5) -- Just 10
|
||||
Maybe.map (\x -> x * 2) Nothing -- Nothing
|
||||
|
||||
-- Chain operations
|
||||
Just "hello"
|
||||
|> Maybe.map String.toUpper
|
||||
|> Maybe.map String.reverse
|
||||
-- Just "OLLEH"
|
||||
```
|
||||
|
||||
#### Maybe.andThen
|
||||
|
||||
Chain operations that might fail:
|
||||
|
||||
```elm
|
||||
-- String.toInt returns Maybe Int
|
||||
String.toInt "42" -- Just 42
|
||||
String.toInt "hello" -- Nothing
|
||||
|
||||
-- Chain fallible operations
|
||||
parseAndDouble : String -> Maybe Int
|
||||
parseAndDouble str =
|
||||
String.toInt str
|
||||
|> Maybe.andThen (\n -> Just (n * 2))
|
||||
|
||||
parseAndDouble "5" -- Just 10
|
||||
parseAndDouble "hello" -- Nothing
|
||||
```
|
||||
|
||||
### Maybe in Practice
|
||||
|
||||
```elm
|
||||
type alias User =
|
||||
{ name : String
|
||||
, email : Maybe String -- Email is optional
|
||||
, age : Maybe Int -- Age is optional
|
||||
}
|
||||
|
||||
displayEmail : User -> String
|
||||
displayEmail user =
|
||||
case user.email of
|
||||
Just email ->
|
||||
email
|
||||
|
||||
Nothing ->
|
||||
"No email provided"
|
||||
|
||||
-- Or more concisely:
|
||||
displayEmail2 : User -> String
|
||||
displayEmail2 user =
|
||||
Maybe.withDefault "No email provided" user.email
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
Pattern matching is a powerful way to destructure data.
|
||||
|
||||
### On Lists
|
||||
|
||||
```elm
|
||||
describeList : List a -> String
|
||||
describeList list =
|
||||
case list of
|
||||
[] ->
|
||||
"Empty list"
|
||||
|
||||
[x] ->
|
||||
"Single element"
|
||||
|
||||
[x, y] ->
|
||||
"Two elements"
|
||||
|
||||
x :: rest ->
|
||||
"Starts with something, has more"
|
||||
```
|
||||
|
||||
### On Maybe
|
||||
|
||||
```elm
|
||||
case maybeName of
|
||||
Just name ->
|
||||
"Hello, " ++ name
|
||||
|
||||
Nothing ->
|
||||
"Hello, stranger"
|
||||
```
|
||||
|
||||
### On Custom Types
|
||||
|
||||
```elm
|
||||
type Status
|
||||
= Loading
|
||||
| Success String
|
||||
| Error String Int
|
||||
|
||||
showStatus : Status -> String
|
||||
showStatus status =
|
||||
case status of
|
||||
Loading ->
|
||||
"Loading..."
|
||||
|
||||
Success message ->
|
||||
"Success: " ++ message
|
||||
|
||||
Error message code ->
|
||||
"Error " ++ String.fromInt code ++ ": " ++ message
|
||||
```
|
||||
|
||||
### Wildcards
|
||||
|
||||
Use `_` to ignore values:
|
||||
|
||||
```elm
|
||||
isLoading : Status -> Bool
|
||||
isLoading status =
|
||||
case status of
|
||||
Loading ->
|
||||
True
|
||||
|
||||
_ ->
|
||||
False -- Matches Success and Error
|
||||
```
|
||||
|
||||
## The Result Type
|
||||
|
||||
`Result` is like `Maybe` but carries error information:
|
||||
|
||||
```elm
|
||||
type Result error value
|
||||
= Ok value
|
||||
| Err error
|
||||
```
|
||||
|
||||
### Example: Parsing Numbers
|
||||
|
||||
```elm
|
||||
parseAge : String -> Result String Int
|
||||
parseAge input =
|
||||
case String.toInt input of
|
||||
Just age ->
|
||||
if age < 0 then
|
||||
Err "Age cannot be negative"
|
||||
else if age > 150 then
|
||||
Err "Age seems unrealistic"
|
||||
else
|
||||
Ok age
|
||||
|
||||
Nothing ->
|
||||
Err "Please enter a valid number"
|
||||
|
||||
-- Using it
|
||||
case parseAge userInput of
|
||||
Ok age ->
|
||||
"Your age is " ++ String.fromInt age
|
||||
|
||||
Err errorMessage ->
|
||||
"Invalid: " ++ errorMessage
|
||||
```
|
||||
|
||||
### Result Functions
|
||||
|
||||
```elm
|
||||
Result.withDefault 0 (Ok 5) -- 5
|
||||
Result.withDefault 0 (Err "oops") -- 0
|
||||
|
||||
Result.map (\x -> x * 2) (Ok 5) -- Ok 10
|
||||
Result.map (\x -> x * 2) (Err "x") -- Err "x"
|
||||
```
|
||||
|
||||
## Exercise 5.1: List Operations
|
||||
|
||||
Given this list of numbers:
|
||||
```elm
|
||||
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
```
|
||||
|
||||
Write expressions to:
|
||||
1. Get all even numbers
|
||||
2. Square each number
|
||||
3. Get the sum of squares of even numbers
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
-- 1. Even numbers
|
||||
evens = List.filter (\n -> modBy 2 n == 0) numbers
|
||||
-- [2, 4, 6, 8, 10]
|
||||
|
||||
-- 2. Square each
|
||||
squares = List.map (\n -> n * n) numbers
|
||||
-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
|
||||
|
||||
-- 3. Sum of squares of evens
|
||||
sumOfSquaresOfEvens =
|
||||
numbers
|
||||
|> List.filter (\n -> modBy 2 n == 0)
|
||||
|> List.map (\n -> n * n)
|
||||
|> List.sum
|
||||
-- 220
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 5.2: Safe Division
|
||||
|
||||
Write a function `safeDivide : Int -> Int -> Maybe Int` that:
|
||||
- Returns `Nothing` if dividing by zero
|
||||
- Returns `Just result` otherwise
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
safeDivide : Int -> Int -> Maybe Int
|
||||
safeDivide numerator denominator =
|
||||
if denominator == 0 then
|
||||
Nothing
|
||||
else
|
||||
Just (numerator // denominator)
|
||||
|
||||
safeDivide 10 2 -- Just 5
|
||||
safeDivide 10 0 -- Nothing
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 5.3: User Lookup
|
||||
|
||||
Given this data structure:
|
||||
|
||||
```elm
|
||||
type alias User =
|
||||
{ id : Int
|
||||
, name : String
|
||||
}
|
||||
|
||||
users : List User
|
||||
users =
|
||||
[ { id = 1, name = "Alice" }
|
||||
, { id = 2, name = "Bob" }
|
||||
, { id = 3, name = "Charlie" }
|
||||
]
|
||||
```
|
||||
|
||||
Write `findUser : Int -> Maybe User` that finds a user by ID.
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
findUser : Int -> Maybe User
|
||||
findUser id =
|
||||
users
|
||||
|> List.filter (\user -> user.id == id)
|
||||
|> List.head
|
||||
|
||||
-- Or using List.find (from List.Extra package)
|
||||
-- findUser id = List.Extra.find (\user -> user.id == id) users
|
||||
|
||||
findUser 2 -- Just { id = 2, name = "Bob" }
|
||||
findUser 99 -- Nothing
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Exercise 5.4: Chain Maybe Operations
|
||||
|
||||
Write a function that:
|
||||
1. Takes a list of strings
|
||||
2. Gets the first element
|
||||
3. Converts it to an integer
|
||||
4. Doubles it
|
||||
|
||||
```elm
|
||||
processFirst : List String -> Maybe Int
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Solution</summary>
|
||||
|
||||
```elm
|
||||
processFirst : List String -> Maybe Int
|
||||
processFirst strings =
|
||||
strings
|
||||
|> List.head
|
||||
|> Maybe.andThen String.toInt
|
||||
|> Maybe.map (\n -> n * 2)
|
||||
|
||||
processFirst ["42", "hello"] -- Just 84
|
||||
processFirst ["hello", "42"] -- Nothing (can't parse)
|
||||
processFirst [] -- Nothing (empty list)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Lists are homogeneous** - All elements must be the same type
|
||||
2. **Lists are immutable** - Operations return new lists
|
||||
3. **Maybe replaces null** - Forces explicit handling of missing values
|
||||
4. **Result carries error info** - Better than just Nothing
|
||||
5. **Pattern matching** is powerful for destructuring data
|
||||
6. **Use `|>` with Maybe.map and Maybe.andThen** for clean chains
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```elm
|
||||
-- Safely get and transform
|
||||
List.head items
|
||||
|> Maybe.map transform
|
||||
|> Maybe.withDefault defaultValue
|
||||
|
||||
-- Chain fallible operations
|
||||
input
|
||||
|> step1
|
||||
|> Maybe.andThen step2
|
||||
|> Maybe.andThen step3
|
||||
|
||||
-- Filter and transform in one go
|
||||
items
|
||||
|> List.filterMap toMaybe
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
In [Lesson 6](06-http-json.md), we'll learn about:
|
||||
- HTTP requests
|
||||
- JSON decoding
|
||||
- Commands and subscriptions
|
||||
- Side effects in Elm
|
||||
|
||||
---
|
||||
|
||||
[← Previous: Lesson 4](04-tea.md) | [Next: Lesson 6 - HTTP & JSON →](06-http-json.md)
|
||||
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)
|
||||
856
lessons/07-final-project.md
Normal file
856
lessons/07-final-project.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# Lesson 7: Final Project - Building a Complete App
|
||||
|
||||
## Learning Goals
|
||||
|
||||
In this final lesson, you will:
|
||||
- Apply everything you've learned
|
||||
- Build a complete, functional application
|
||||
- Understand how to structure larger Elm projects
|
||||
- Learn about ports for JavaScript interop
|
||||
|
||||
## Project: Task Manager
|
||||
|
||||
We'll build a task manager with:
|
||||
- Add, complete, and delete tasks
|
||||
- Filter tasks (all, active, completed)
|
||||
- Persist tasks to localStorage (using ports)
|
||||
- Clean, modular code structure
|
||||
|
||||
## Setting Up
|
||||
|
||||
```bash
|
||||
mkdir task-manager
|
||||
cd task-manager
|
||||
elm init
|
||||
```
|
||||
|
||||
## The Complete Application
|
||||
|
||||
Create `src/Main.elm`:
|
||||
|
||||
```elm
|
||||
port module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
|
||||
-- PORTS
|
||||
|
||||
|
||||
port saveTasks : Encode.Value -> Cmd msg
|
||||
|
||||
|
||||
port loadTasks : (Decode.Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Task =
|
||||
{ id : Int
|
||||
, description : String
|
||||
, completed : Bool
|
||||
}
|
||||
|
||||
|
||||
type Filter
|
||||
= All
|
||||
| Active
|
||||
| Completed
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ tasks : List Task
|
||||
, newTask : String
|
||||
, nextId : Int
|
||||
, filter : Filter
|
||||
}
|
||||
|
||||
|
||||
init : Decode.Value -> ( Model, Cmd Msg )
|
||||
init flags =
|
||||
let
|
||||
loadedTasks =
|
||||
case Decode.decodeValue tasksDecoder flags of
|
||||
Ok tasks ->
|
||||
tasks
|
||||
|
||||
Err _ ->
|
||||
[]
|
||||
|
||||
nextId =
|
||||
case List.maximum (List.map .id loadedTasks) of
|
||||
Just maxId ->
|
||||
maxId + 1
|
||||
|
||||
Nothing ->
|
||||
1
|
||||
in
|
||||
( { tasks = loadedTasks
|
||||
, newTask = ""
|
||||
, nextId = nextId
|
||||
, filter = All
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- DECODERS & ENCODERS
|
||||
|
||||
|
||||
taskDecoder : Decoder Task
|
||||
taskDecoder =
|
||||
Decode.map3 Task
|
||||
(Decode.field "id" Decode.int)
|
||||
(Decode.field "description" Decode.string)
|
||||
(Decode.field "completed" Decode.bool)
|
||||
|
||||
|
||||
tasksDecoder : Decoder (List Task)
|
||||
tasksDecoder =
|
||||
Decode.list taskDecoder
|
||||
|
||||
|
||||
encodeTask : Task -> Encode.Value
|
||||
encodeTask task =
|
||||
Encode.object
|
||||
[ ( "id", Encode.int task.id )
|
||||
, ( "description", Encode.string task.description )
|
||||
, ( "completed", Encode.bool task.completed )
|
||||
]
|
||||
|
||||
|
||||
encodeTasks : List Task -> Encode.Value
|
||||
encodeTasks tasks =
|
||||
Encode.list encodeTask tasks
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateNewTask String
|
||||
| AddTask
|
||||
| ToggleTask Int
|
||||
| DeleteTask Int
|
||||
| SetFilter Filter
|
||||
| ClearCompleted
|
||||
| LoadedTasks Decode.Value
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateNewTask text ->
|
||||
( { model | newTask = text }, Cmd.none )
|
||||
|
||||
AddTask ->
|
||||
if String.isEmpty (String.trim model.newTask) then
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
let
|
||||
newTask =
|
||||
{ id = model.nextId
|
||||
, description = String.trim model.newTask
|
||||
, completed = False
|
||||
}
|
||||
|
||||
newTasks =
|
||||
model.tasks ++ [ newTask ]
|
||||
in
|
||||
( { model
|
||||
| tasks = newTasks
|
||||
, newTask = ""
|
||||
, nextId = model.nextId + 1
|
||||
}
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
ToggleTask id ->
|
||||
let
|
||||
toggleTask task =
|
||||
if task.id == id then
|
||||
{ task | completed = not task.completed }
|
||||
|
||||
else
|
||||
task
|
||||
|
||||
newTasks =
|
||||
List.map toggleTask model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
DeleteTask id ->
|
||||
let
|
||||
newTasks =
|
||||
List.filter (\task -> task.id /= id) model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
SetFilter filter ->
|
||||
( { model | filter = filter }, Cmd.none )
|
||||
|
||||
ClearCompleted ->
|
||||
let
|
||||
newTasks =
|
||||
List.filter (\task -> not task.completed) model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
LoadedTasks value ->
|
||||
case Decode.decodeValue tasksDecoder value of
|
||||
Ok tasks ->
|
||||
( { model | tasks = tasks }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "container" ]
|
||||
[ h1 [] [ text "Task Manager" ]
|
||||
, viewInput model.newTask
|
||||
, viewFilters model.filter
|
||||
, viewTasks model
|
||||
, viewFooter model
|
||||
]
|
||||
|
||||
|
||||
viewInput : String -> Html Msg
|
||||
viewInput newTask =
|
||||
div [ class "input-section" ]
|
||||
[ input
|
||||
[ placeholder "What needs to be done?"
|
||||
, value newTask
|
||||
, onInput UpdateNewTask
|
||||
, onEnter AddTask
|
||||
]
|
||||
[]
|
||||
, button [ onClick AddTask ] [ text "Add" ]
|
||||
]
|
||||
|
||||
|
||||
onEnter : Msg -> Attribute Msg
|
||||
onEnter msg =
|
||||
let
|
||||
isEnter code =
|
||||
if code == 13 then
|
||||
Decode.succeed msg
|
||||
|
||||
else
|
||||
Decode.fail "not ENTER"
|
||||
in
|
||||
on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int))
|
||||
|
||||
|
||||
viewFilters : Filter -> Html Msg
|
||||
viewFilters currentFilter =
|
||||
div [ class "filters" ]
|
||||
[ filterButton All currentFilter "All"
|
||||
, filterButton Active currentFilter "Active"
|
||||
, filterButton Completed currentFilter "Completed"
|
||||
]
|
||||
|
||||
|
||||
filterButton : Filter -> Filter -> String -> Html Msg
|
||||
filterButton filter currentFilter label =
|
||||
button
|
||||
[ classList [ ( "active", filter == currentFilter ) ]
|
||||
, onClick (SetFilter filter)
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
viewTasks : Model -> Html Msg
|
||||
viewTasks model =
|
||||
let
|
||||
filteredTasks =
|
||||
filterTasks model.filter model.tasks
|
||||
in
|
||||
if List.isEmpty model.tasks then
|
||||
p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ]
|
||||
|
||||
else if List.isEmpty filteredTasks then
|
||||
p [ class "empty-message" ] [ text "No tasks match this filter." ]
|
||||
|
||||
else
|
||||
ul [ class "task-list" ]
|
||||
(List.map viewTask filteredTasks)
|
||||
|
||||
|
||||
filterTasks : Filter -> List Task -> List Task
|
||||
filterTasks filter tasks =
|
||||
case filter of
|
||||
All ->
|
||||
tasks
|
||||
|
||||
Active ->
|
||||
List.filter (\t -> not t.completed) tasks
|
||||
|
||||
Completed ->
|
||||
List.filter .completed tasks
|
||||
|
||||
|
||||
viewTask : Task -> Html Msg
|
||||
viewTask task =
|
||||
li [ classList [ ( "completed", task.completed ) ] ]
|
||||
[ input
|
||||
[ type_ "checkbox"
|
||||
, checked task.completed
|
||||
, onClick (ToggleTask task.id)
|
||||
]
|
||||
[]
|
||||
, span [ class "task-description" ] [ text task.description ]
|
||||
, button
|
||||
[ class "delete-btn"
|
||||
, onClick (DeleteTask task.id)
|
||||
]
|
||||
[ text "x" ]
|
||||
]
|
||||
|
||||
|
||||
viewFooter : Model -> Html Msg
|
||||
viewFooter model =
|
||||
let
|
||||
activeCount =
|
||||
List.length (List.filter (\t -> not t.completed) model.tasks)
|
||||
|
||||
completedCount =
|
||||
List.length (List.filter .completed model.tasks)
|
||||
|
||||
itemWord =
|
||||
if activeCount == 1 then
|
||||
"item"
|
||||
|
||||
else
|
||||
"items"
|
||||
in
|
||||
if List.isEmpty model.tasks then
|
||||
text ""
|
||||
|
||||
else
|
||||
div [ class "footer" ]
|
||||
[ span []
|
||||
[ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left")
|
||||
]
|
||||
, if completedCount > 0 then
|
||||
button [ onClick ClearCompleted ]
|
||||
[ text "Clear completed" ]
|
||||
|
||||
else
|
||||
text ""
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions _ =
|
||||
loadTasks LoadedTasks
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Decode.Value Model Msg
|
||||
main =
|
||||
Browser.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
}
|
||||
```
|
||||
|
||||
## The HTML File with JavaScript
|
||||
|
||||
Create `index.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Task Manager</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-section input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-section input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.input-section button {
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.input-section button:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filters button:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.filters button.active {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.task-list li:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.task-list li.completed .task-description {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.task-list input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.task-list li:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer button:hover {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="elm.js"></script>
|
||||
<script>
|
||||
// Load saved tasks from localStorage
|
||||
const savedTasks = localStorage.getItem('elm-tasks');
|
||||
const initialTasks = savedTasks ? JSON.parse(savedTasks) : [];
|
||||
|
||||
// Initialize Elm app with saved tasks
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('app'),
|
||||
flags: initialTasks
|
||||
});
|
||||
|
||||
// Listen for save commands from Elm
|
||||
app.ports.saveTasks.subscribe(function(tasks) {
|
||||
localStorage.setItem('elm-tasks', JSON.stringify(tasks));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Building and Running
|
||||
|
||||
```bash
|
||||
# Compile Elm to JavaScript
|
||||
elm make src/Main.elm --output=elm.js
|
||||
|
||||
# Open index.html in your browser
|
||||
# On macOS: open index.html
|
||||
# On Linux: xdg-open index.html
|
||||
# On Windows: start index.html
|
||||
|
||||
# Or use a local server
|
||||
python -m http.server 8000
|
||||
# Then open http://localhost:8000
|
||||
```
|
||||
|
||||
## Understanding Ports
|
||||
|
||||
Ports allow Elm to communicate with JavaScript for side effects Elm can't handle natively.
|
||||
|
||||
### Defining Ports
|
||||
|
||||
```elm
|
||||
port module Main exposing (main)
|
||||
|
||||
-- Outgoing: Elm -> JavaScript
|
||||
port saveTasks : Encode.Value -> Cmd msg
|
||||
|
||||
-- Incoming: JavaScript -> Elm
|
||||
port loadTasks : (Decode.Value -> msg) -> Sub msg
|
||||
```
|
||||
|
||||
### JavaScript Side
|
||||
|
||||
```javascript
|
||||
// Subscribe to Elm's outgoing port
|
||||
app.ports.saveTasks.subscribe(function(tasks) {
|
||||
localStorage.setItem('elm-tasks', JSON.stringify(tasks));
|
||||
});
|
||||
|
||||
// Send data to Elm's incoming port
|
||||
app.ports.loadTasks.send(someData);
|
||||
```
|
||||
|
||||
### Flags: Initial Data
|
||||
|
||||
```elm
|
||||
-- Elm receives flags in init
|
||||
init : Decode.Value -> ( Model, Cmd Msg )
|
||||
init flags =
|
||||
-- Decode flags (initial tasks from localStorage)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript passes flags at startup
|
||||
Elm.Main.init({
|
||||
node: document.getElementById('app'),
|
||||
flags: initialTasks
|
||||
});
|
||||
```
|
||||
|
||||
## Code Organization Tips
|
||||
|
||||
### For Larger Apps
|
||||
|
||||
```
|
||||
src/
|
||||
├── Main.elm # Entry point
|
||||
├── Model.elm # Model type and init
|
||||
├── Update.elm # Update function and messages
|
||||
├── View.elm # View functions
|
||||
├── Types.elm # Shared types
|
||||
├── Api.elm # HTTP requests
|
||||
└── Ports.elm # Port declarations
|
||||
```
|
||||
|
||||
### Splitting the Model
|
||||
|
||||
```elm
|
||||
-- Types.elm
|
||||
module Types exposing (..)
|
||||
|
||||
type alias Task =
|
||||
{ id : Int
|
||||
, description : String
|
||||
, completed : Bool
|
||||
}
|
||||
|
||||
type Filter
|
||||
= All
|
||||
| Active
|
||||
| Completed
|
||||
```
|
||||
|
||||
### Helper Functions
|
||||
|
||||
Extract common patterns:
|
||||
|
||||
```elm
|
||||
-- Helpers.elm
|
||||
module Helpers exposing (..)
|
||||
|
||||
updateTask : Int -> (Task -> Task) -> List Task -> List Task
|
||||
updateTask targetId transform tasks =
|
||||
List.map
|
||||
(\task ->
|
||||
if task.id == targetId then
|
||||
transform task
|
||||
else
|
||||
task
|
||||
)
|
||||
tasks
|
||||
```
|
||||
|
||||
## Testing Your Elm Code
|
||||
|
||||
### Install elm-test
|
||||
|
||||
```bash
|
||||
npm install -g elm-test
|
||||
elm-test init
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```elm
|
||||
-- tests/TaskTests.elm
|
||||
module TaskTests exposing (..)
|
||||
|
||||
import Expect
|
||||
import Test exposing (..)
|
||||
import Main exposing (filterTasks, Filter(..))
|
||||
|
||||
|
||||
suite : Test
|
||||
suite =
|
||||
describe "Task filtering"
|
||||
[ test "All filter shows all tasks" <|
|
||||
\_ ->
|
||||
let
|
||||
tasks =
|
||||
[ { id = 1, description = "Task 1", completed = False }
|
||||
, { id = 2, description = "Task 2", completed = True }
|
||||
]
|
||||
in
|
||||
filterTasks All tasks
|
||||
|> List.length
|
||||
|> Expect.equal 2
|
||||
|
||||
, test "Active filter shows only incomplete" <|
|
||||
\_ ->
|
||||
let
|
||||
tasks =
|
||||
[ { id = 1, description = "Task 1", completed = False }
|
||||
, { id = 2, description = "Task 2", completed = True }
|
||||
]
|
||||
in
|
||||
filterTasks Active tasks
|
||||
|> List.length
|
||||
|> Expect.equal 1
|
||||
]
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
elm-test
|
||||
```
|
||||
|
||||
## Exercises
|
||||
|
||||
### Exercise 7.1: Add Edit Functionality
|
||||
|
||||
Add the ability to edit task descriptions:
|
||||
1. Double-click a task to edit it
|
||||
2. Press Enter to save
|
||||
3. Press Escape to cancel
|
||||
|
||||
<details>
|
||||
<summary>Hint</summary>
|
||||
|
||||
Add a new field to the model:
|
||||
```elm
|
||||
type alias Model =
|
||||
{ tasks : List Task
|
||||
, newTask : String
|
||||
, nextId : Int
|
||||
, filter : Filter
|
||||
, editing : Maybe Int -- ID of task being edited
|
||||
, editText : String
|
||||
}
|
||||
```
|
||||
|
||||
Add new messages:
|
||||
```elm
|
||||
type Msg
|
||||
= ...
|
||||
| StartEditing Int String
|
||||
| UpdateEditText String
|
||||
| SaveEdit
|
||||
| CancelEdit
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Exercise 7.2: Add Due Dates
|
||||
|
||||
Extend tasks to have optional due dates:
|
||||
1. Add a date input when creating tasks
|
||||
2. Display due dates with tasks
|
||||
3. Highlight overdue tasks
|
||||
|
||||
### Exercise 7.3: Add Categories
|
||||
|
||||
Add task categories:
|
||||
1. Tasks can be tagged with a category
|
||||
2. Filter by category
|
||||
3. Add color coding
|
||||
|
||||
## Key Takeaways from This Tutorial
|
||||
|
||||
1. **Elm is purely functional** - All functions are pure, side effects are managed
|
||||
2. **Types are your friend** - They catch bugs at compile time
|
||||
3. **The Elm Architecture** - Model, View, Update provides clean structure
|
||||
4. **Maybe eliminates nulls** - No more "undefined is not a function"
|
||||
5. **JSON decoders** ensure type-safe API integration
|
||||
6. **Ports** bridge Elm and JavaScript for browser APIs
|
||||
|
||||
## Where to Go From Here
|
||||
|
||||
### Resources
|
||||
|
||||
- [Official Elm Guide](https://guide.elm-lang.org/) - The canonical resource
|
||||
- [Elm in Action](https://www.manning.com/books/elm-in-action) - Excellent book
|
||||
- [Elm Packages](https://package.elm-lang.org/) - Browse available libraries
|
||||
- [Elm Slack](https://elmlang.herokuapp.com/) - Friendly community
|
||||
|
||||
### Practice Projects
|
||||
|
||||
1. **Weather App** - Fetch and display weather data
|
||||
2. **Markdown Preview** - Live markdown editor with preview
|
||||
3. **Kanban Board** - Drag-and-drop task management
|
||||
4. **Chat Application** - WebSocket integration with ports
|
||||
|
||||
### Advanced Topics to Explore
|
||||
|
||||
- **elm-ui** - Layout without CSS
|
||||
- **elm-spa** - Single Page Applications
|
||||
- **GraphQL with elm-graphql**
|
||||
- **Web Components interop**
|
||||
|
||||
## Congratulations!
|
||||
|
||||
You've completed this Elm tutorial! You now have:
|
||||
- A solid understanding of functional programming concepts
|
||||
- Knowledge of Elm's type system
|
||||
- Experience with The Elm Architecture
|
||||
- Skills to build real-world applications
|
||||
|
||||
The best way to learn more is to build something. Pick a project and start coding!
|
||||
|
||||
---
|
||||
|
||||
[← Previous: Lesson 6](06-http-json.md) | [Back to README](../README.md)
|
||||
Reference in New Issue
Block a user