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
10 KiB
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).
-- 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
-- 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
List.range 1 5 -- [1, 2, 3, 4, 5]
List.repeat 3 "hi" -- ["hi", "hi", "hi"]
List.singleton 42 -- [42]
Transforming Lists
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
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
[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)
-- 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
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.
type Maybe a
= Just a
| Nothing
Why Maybe?
// JavaScript - Runtime crash waiting to happen
function getFirst(arr) {
return arr[0]; // undefined if empty!
}
const first = getFirst([]);
console.log(first.toUpperCase()); // CRASH!
-- 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)
showResult : Maybe Int -> String
showResult maybeNum =
case maybeNum of
Just n ->
"The number is " ++ String.fromInt n
Nothing ->
"No number found"
Maybe.withDefault
-- 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:
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:
-- 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
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
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
case maybeName of
Just name ->
"Hello, " ++ name
Nothing ->
"Hello, stranger"
On Custom Types
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:
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:
type Result error value
= Ok value
| Err error
Example: Parsing Numbers
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
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:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Write expressions to:
- Get all even numbers
- Square each number
- Get the sum of squares of even numbers
Solution
-- 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
Exercise 5.2: Safe Division
Write a function safeDivide : Int -> Int -> Maybe Int that:
- Returns
Nothingif dividing by zero - Returns
Just resultotherwise
Solution
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
Exercise 5.3: User Lookup
Given this data structure:
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.
Solution
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
Exercise 5.4: Chain Maybe Operations
Write a function that:
- Takes a list of strings
- Gets the first element
- Converts it to an integer
- Doubles it
processFirst : List String -> Maybe Int
Solution
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)
Key Takeaways
- Lists are homogeneous - All elements must be the same type
- Lists are immutable - Operations return new lists
- Maybe replaces null - Forces explicit handling of missing values
- Result carries error info - Better than just Nothing
- Pattern matching is powerful for destructuring data
- Use
|>with Maybe.map and Maybe.andThen for clean chains
Common Patterns
-- 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, we'll learn about:
- HTTP requests
- JSON decoding
- Commands and subscriptions
- Side effects in Elm