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
534 lines
10 KiB
Markdown
534 lines
10 KiB
Markdown
# 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)
|