Files
elm/lessons/05-lists-maybe.md
Mark Gerrard 7d7986f3ab Initial commit: Elm tutorial for JavaScript developers
This tutorial includes:
- 7 progressive lessons covering Elm fundamentals
- Exercises with starter code
- Solutions for exercises
- Final project: Task Manager app with localStorage persistence

Topics covered:
- Basic syntax and types
- Functions and functional programming concepts
- The Elm Architecture (Model-View-Update)
- Lists and Maybe types
- HTTP requests and JSON decoding
- Ports for JavaScript interop
2026-03-11 11:07:15 +00:00

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:

  1. Get all even numbers
  2. Square each number
  3. 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 Nothing if dividing by zero
  • Returns Just result otherwise
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:

  1. Takes a list of strings
  2. Gets the first element
  3. Converts it to an integer
  4. 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

  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

-- 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

← Previous: Lesson 4 | Next: Lesson 6 - HTTP & JSON →