| name | lang-elm-dev |
| description | Foundational Elm development patterns covering The Elm Architecture (TEA), type-safe frontend development, and core language features. Use when building Elm applications, understanding functional patterns in web development, working with union types and Maybe/Result, or needing guidance on Elm tooling and ecosystem. |
Elm Development
Foundational patterns for building type-safe frontend applications with Elm. This skill covers The Elm Architecture, core language features, and Elm's unique approach to functional web development.
Overview
Elm is a purely functional language for building reliable web applications with no runtime exceptions. It compiles to JavaScript and enforces immutability, pure functions, and explicit error handling through its type system.
This skill covers:
- The Elm Architecture (Model-View-Update pattern)
- Core syntax (types, functions, pattern matching)
- Type system (union types, type aliases, Maybe/Result)
- Working with JSON and HTTP
- Common patterns and idioms
- Elm tooling (elm-format, elm-review, elm-test)
This skill does NOT cover:
- Advanced UI patterns → See elm-ui or elm-css specific skills
- Complex state management → See elm-spa patterns
- Backend integration specifics → See framework-specific docs
- JavaScript interop deep-dives → See ports/flags documentation
Quick Reference
| Task | Pattern |
|---|---|
| Define type alias | type alias User = { name : String, age : Int } |
| Define union type | type Msg = Increment | Decrement |
| Pattern match | case msg of ... |
| Handle Maybe | Maybe.withDefault default maybeValue |
| Handle Result | Result.withDefault default result |
| Update function | update : Msg -> Model -> ( Model, Cmd Msg ) |
| View function | view : Model -> Html Msg |
| HTTP request | Http.get { url = "...", expect = Http.expectJson ... } |
| Decode JSON | Decode.field "name" Decode.string |
Module System
Elm uses a simple but strict module system where every file is a module and must declare what it exposes.
Module Declaration
-- Every file MUST start with a module declaration
module MyModule exposing (publicFunction, PublicType, PublicMsg(..))
-- Expose everything (not recommended for libraries)
module MyModule exposing (..)
-- Expose specific items
module MyModule exposing
( User -- Type but not constructors
, Msg(..) -- Type with all constructors
, init -- Function
, update -- Function
)
-- Port modules (for JavaScript interop)
port module Main exposing (..)
Import Syntax
-- Basic import (must qualify: Dict.empty)
import Dict
-- Import with alias (shorter qualification)
import Json.Decode as Decode
import Json.Encode as E
-- Import exposing specific items (can use unqualified)
import Html exposing (Html, div, text, button)
import Html.Events exposing (onClick, onInput)
-- Import exposing everything (use sparingly)
import List exposing (..)
-- Combined: alias + exposing
import Html.Attributes as Attr exposing (class, id)
-- Can use: Attr.style or class (unqualified)
-- Import type constructors
import Maybe exposing (Maybe(..)) -- Exposes Just and Nothing
import Result exposing (Result(..)) -- Exposes Ok and Err
Module Organization
-- Recommended project structure:
-- src/
-- Main.elm -- Entry point
-- Types.elm -- Shared types
-- Api.elm -- HTTP/API functions
-- Page/
-- Home.elm -- Page modules
-- Users.elm
-- Components/
-- Button.elm -- Reusable components
-- Modal.elm
-- Util/
-- Time.elm -- Helper functions
-- Types.elm - Shared across modules
module Types exposing (User, Msg(..), Route(..))
type alias User =
{ name : String
, email : String
}
type Msg
= NavigateTo Route
| GotUsers (Result Http.Error (List User))
type Route
= Home
| Users
| User Int
Visibility and Encapsulation
-- Types.elm: Hide implementation, expose interface
module Email exposing (Email, fromString, toString)
-- Opaque type: Constructor is NOT exposed
type Email = Email String
-- Only way to create an Email is through validation
fromString : String -> Maybe Email
fromString str =
if String.contains "@" str then
Just (Email str)
else
Nothing
-- Only way to get the string back
toString : Email -> String
toString (Email str) =
str
-- Users CANNOT:
-- Email "invalid" -- Error: Email constructor not exposed
-- case email of Email str -> str -- Error: Cannot pattern match
-- Users CAN:
-- Email.fromString "test@example.com" |> Maybe.map Email.toString
Avoiding Circular Imports
-- Problem: A imports B, B imports A → IMPORT CYCLE error
-- Solution: Extract shared types to separate module
-- Before (circular):
-- User.elm imports Main (for Msg)
-- Main.elm imports User (for User type)
-- After (fixed):
-- Types.elm (shared types)
module Types exposing (User, Msg(..))
type alias User = { name : String }
type Msg = SetUser User | LoadUsers
-- User.elm imports Types
module User exposing (view)
import Types exposing (User, Msg(..))
-- Main.elm imports Types
module Main exposing (main)
import Types exposing (User, Msg(..))
Zero and Default Values
Elm takes a fundamentally different approach to "zero" or "default" values compared to most languages. There are no nulls, no undefined, and no implicit defaults.
No Null/Undefined/Nil
-- Elm has NO:
-- - null
-- - undefined
-- - nil
-- - None (unless you define it)
-- - default constructors
-- Every value MUST be explicitly initialized
-- Every field MUST have a value
-- This is INVALID:
-- user = { name = "Alice" } -- Error: missing email, age fields
-- This is VALID:
type alias User =
{ name : String
, email : String
, age : Int
}
user : User
user =
{ name = "Alice"
, email = "alice@example.com"
, age = 30
}
Maybe for Optional Values
-- Instead of null, use Maybe for values that might not exist
type Maybe a
= Just a
| Nothing
-- Optional field
type alias UserProfile =
{ name : String
, nickname : Maybe String -- Explicit: might not have one
, bio : Maybe String -- Explicit: might not have one
}
-- Creating with optional fields
profile : UserProfile
profile =
{ name = "Alice"
, nickname = Nothing -- Explicitly no nickname
, bio = Just "Developer" -- Has a bio
}
-- Must handle both cases - compiler enforces this
displayNickname : UserProfile -> String
displayNickname user =
case user.nickname of
Just nick ->
nick
Nothing ->
user.name -- Fallback to name
Providing Defaults Explicitly
-- Pattern: Create "empty" or "default" values as functions
emptyUser : User
emptyUser =
{ name = ""
, email = ""
, age = 0
}
-- Pattern: Defaults with Maybe
withDefault : a -> Maybe a -> a
withDefault default maybe =
case maybe of
Just value ->
value
Nothing ->
default
-- Usage
age : Int
age =
user.age
|> String.toInt
|> Maybe.withDefault 0 -- Explicit default
-- Pattern: Defaults with Result
defaultOnError : a -> Result error a -> a
defaultOnError default result =
case result of
Ok value ->
value
Err _ ->
default
Comparison to Other Languages
-- JavaScript:
-- const name = user?.name ?? "Anonymous"
-- const items = response.data || []
-- Elm equivalent:
name : String
name =
user
|> Maybe.map .name
|> Maybe.withDefault "Anonymous"
items : List Item
items =
response.data
|> Result.withDefault []
-- TypeScript:
-- interface User { name?: string } -- Optional property
-- Elm: Make it explicit
type alias User =
{ name : Maybe String } -- Explicit Maybe
-- Or use variant types for more control
type UserName
= Anonymous
| Named String
Why This Matters for Conversion
-- When converting FROM languages with nulls:
-- 1. Identify optional values → use Maybe
-- 2. Identify fallback patterns → use withDefault
-- 3. Identify nullable parameters → use Maybe parameters
-- 4. Identify nullable returns → use Maybe returns
-- When converting TO languages with nulls:
-- 1. Maybe Nothing → null/None/nil
-- 2. Maybe (Just x) → x
-- 3. Maybe.withDefault default → ?? or || operators
The Elm Architecture (TEA)
The Elm Architecture is the core pattern for all Elm applications. It consists of Model, View, and Update.
Basic Structure
module Main exposing (main)
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
-- MODEL
type alias Model =
{ count : Int
}
init : () -> ( Model, Cmd Msg )
init _ =
( { count = 0 }, Cmd.none )
-- UPDATE
type Msg
= Increment
| Decrement
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment ->
( { model | count = model.count + 1 }, Cmd.none )
Decrement ->
( { model | count = model.count - 1 }, Cmd.none )
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (String.fromInt model.count) ]
, button [ onClick Increment ] [ text "+" ]
]
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}
TEA Flow
┌─────────────────────────────────────────────────┐
│ The Elm Architecture │
├─────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ │
│ │ init │ ──▶ ( Model, Cmd Msg ) │
│ └───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ User │
│ │ Model │ ◀─── Event │
│ └───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ view │ ──▶ Html Msg │
│ └───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ Msg │ (user clicks button) │
│ └───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ update │ ──▶ ( Model, Cmd Msg ) │
│ └───────────┘ │
│ │ │
│ └──────────▶ (loop back to Model) │
│ │
└─────────────────────────────────────────────────┘
Key Principles:
- Model holds all application state
- View is a pure function: Model → Html Msg
- Update is a pure function: Msg → Model → (Model, Cmd Msg)
- All side effects through Cmd (commands) and Sub (subscriptions)
Core Types
Type Aliases
-- Simple record type
type alias User =
{ name : String
, email : String
, age : Int
}
-- Creating instances
user : User
user =
{ name = "Alice"
, email = "alice@example.com"
, age = 30
}
-- Updating records (immutable)
updatedUser : User
updatedUser =
{ user | age = 31 }
-- Nested records
type alias Address =
{ street : String
, city : String
}
type alias Person =
{ name : String
, address : Address
}
Union Types (Custom Types)
-- Simple union type
type Direction
= North
| South
| East
| West
-- Union type with data
type Msg
= NoOp
| SetName String
| SetAge Int
| Login { username : String, password : String }
-- Using union types
handleMsg : Msg -> Model -> Model
handleMsg msg model =
case msg of
NoOp ->
model
SetName name ->
{ model | name = name }
SetAge age ->
{ model | age = age }
Login { username, password } ->
-- Handle login
model
Maybe and Result
-- Maybe: value that might not exist
type Maybe a
= Just a
| Nothing
-- Using Maybe
findUser : Int -> Maybe User
findUser id =
if id == 1 then
Just { name = "Alice", email = "alice@example.com", age = 30 }
else
Nothing
-- Pattern matching Maybe
displayName : Maybe User -> String
displayName maybeUser =
case maybeUser of
Just user ->
user.name
Nothing ->
"Anonymous"
-- Maybe helpers
name : String
name =
findUser 1
|> Maybe.map .name
|> Maybe.withDefault "Anonymous"
-- Result: operation that might fail
type Result error value
= Ok value
| Err error
-- Using Result
parseAge : String -> Result String Int
parseAge str =
case String.toInt str of
Just age ->
if age >= 0 then
Ok age
else
Err "Age must be non-negative"
Nothing ->
Err "Not a valid number"
-- Chaining Results
validateAge : String -> Result String Int
validateAge str =
parseAge str
|> Result.andThen (\age ->
if age < 120 then
Ok age
else
Err "Age must be less than 120"
)
Pattern Matching
Case Expressions
-- Basic case
describeNumber : Int -> String
describeNumber n =
case n of
0 ->
"zero"
1 ->
"one"
_ ->
"other"
-- Multiple patterns
describeList : List a -> String
describeList list =
case list of
[] ->
"empty"
[ x ] ->
"singleton"
[ x, y ] ->
"pair"
x :: xs ->
"list with multiple elements"
-- Destructuring records
greet : User -> String
greet user =
case user of
{ name, age } ->
"Hello " ++ name ++ ", age " ++ String.fromInt age
If-Let Pattern
-- Guards in case expressions
classify : Int -> String
classify n =
case n of
x ->
if x < 0 then
"negative"
else if x == 0 then
"zero"
else
"positive"
-- Let-in for local bindings
calculate : Int -> Int
calculate x =
let
doubled =
x * 2
squared =
x * x
in
doubled + squared
Functions
Function Syntax
-- Simple function
add : Int -> Int -> Int
add x y =
x + y
-- Anonymous function (lambda)
increment : Int -> Int
increment =
\x -> x + 1
-- Partial application
add5 : Int -> Int
add5 =
add 5
-- Pipeline operator
result : Int
result =
10
|> add 5
|> multiply 2
|> subtract 3
-- Composition operator
addThenDouble : Int -> Int -> Int
addThenDouble =
add >> multiply 2
Higher-Order Functions
-- Map
doubled : List Int
doubled =
List.map (\x -> x * 2) [ 1, 2, 3, 4, 5 ]
-- Filter
evens : List Int
evens =
List.filter (\x -> modBy 2 x == 0) [ 1, 2, 3, 4, 5 ]
-- Fold (reduce)
sum : Int
sum =
List.foldl (+) 0 [ 1, 2, 3, 4, 5 ]
-- Chain operations
processNumbers : List Int -> Int
processNumbers numbers =
numbers
|> List.filter (\x -> x > 0)
|> List.map (\x -> x * 2)
|> List.foldl (+) 0
Working with JSON
JSON Decoders
import Json.Decode as Decode exposing (Decoder)
-- Simple decoder
userDecoder : Decoder User
userDecoder =
Decode.map3 User
(Decode.field "name" Decode.string)
(Decode.field "email" Decode.string)
(Decode.field "age" Decode.int)
-- Alternative syntax with succeed and pipeline
import Json.Decode.Pipeline exposing (required, optional)
userDecoder : Decoder User
userDecoder =
Decode.succeed User
|> required "name" Decode.string
|> required "email" Decode.string
|> required "age" Decode.int
-- Decoding lists
usersDecoder : Decoder (List User)
usersDecoder =
Decode.list userDecoder
-- Decoding nested objects
type alias Response =
{ data : List User
, total : Int
}
responseDecoder : Decoder Response
responseDecoder =
Decode.map2 Response
(Decode.field "data" (Decode.list userDecoder))
(Decode.field "total" Decode.int)
-- Optional fields
type alias Config =
{ apiUrl : String
, timeout : Maybe Int
}
configDecoder : Decoder Config
configDecoder =
Decode.map2 Config
(Decode.field "apiUrl" Decode.string)
(Decode.maybe (Decode.field "timeout" Decode.int))
JSON Encoders
import Json.Encode as Encode
-- Encode user to JSON
encodeUser : User -> Encode.Value
encodeUser user =
Encode.object
[ ( "name", Encode.string user.name )
, ( "email", Encode.string user.email )
, ( "age", Encode.int user.age )
]
-- Encode list
encodeUsers : List User -> Encode.Value
encodeUsers users =
Encode.list encodeUser users
-- Optional fields
encodeConfig : Config -> Encode.Value
encodeConfig config =
case config.timeout of
Just timeout ->
Encode.object
[ ( "apiUrl", Encode.string config.apiUrl )
, ( "timeout", Encode.int timeout )
]
Nothing ->
Encode.object
[ ( "apiUrl", Encode.string config.apiUrl )
]
HTTP Requests
Making HTTP Requests
import Http
import Json.Decode as Decode
-- GET request
type Msg
= GotUsers (Result Http.Error (List User))
getUsers : Cmd Msg
getUsers =
Http.get
{ url = "https://api.example.com/users"
, expect = Http.expectJson GotUsers (Decode.list userDecoder)
}
-- POST request
createUser : User -> Cmd Msg
createUser user =
Http.post
{ url = "https://api.example.com/users"
, body = Http.jsonBody (encodeUser user)
, expect = Http.expectJson GotCreateUserResponse userDecoder
}
-- Custom request
updateUser : Int -> User -> Cmd Msg
updateUser id user =
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" "Bearer token" ]
, url = "https://api.example.com/users/" ++ String.fromInt id
, body = Http.jsonBody (encodeUser user)
, expect = Http.expectJson GotUpdateUserResponse userDecoder
, timeout = Nothing
, tracker = Nothing
}
-- Handling HTTP results in update
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotUsers result ->
case result of
Ok users ->
( { model | users = users, error = Nothing }, Cmd.none )
Err error ->
( { model | error = Just (httpErrorToString error) }, Cmd.none )
-- HTTP error handling
httpErrorToString : Http.Error -> String
httpErrorToString 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 body ->
"Bad body: " ++ body
Common Patterns
Loading States
type RemoteData error value
= NotAsked
| Loading
| Success value
| Failure error
type alias Model =
{ users : RemoteData Http.Error (List User)
}
-- In update
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchUsers ->
( { model | users = Loading }, getUsers )
GotUsers result ->
case result of
Ok users ->
( { model | users = Success users }, Cmd.none )
Err error ->
( { model | users = Failure error }, Cmd.none )
-- In view
view : Model -> Html Msg
view model =
case model.users of
NotAsked ->
button [ onClick FetchUsers ] [ text "Load Users" ]
Loading ->
div [] [ text "Loading..." ]
Success users ->
div [] (List.map viewUser users)
Failure error ->
div [] [ text ("Error: " ++ httpErrorToString error) ]
Form Handling
type alias Form =
{ name : String
, email : String
, errors : List String
}
type Msg
= SetName String
| SetEmail String
| Submit
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SetName name ->
( { model | form = updateFormName name model.form }, Cmd.none )
SetEmail email ->
( { model | form = updateFormEmail email model.form }, Cmd.none )
Submit ->
case validateForm model.form of
Ok validForm ->
( model, submitForm validForm )
Err errors ->
( { model | form = setFormErrors errors model.form }, Cmd.none )
-- View with form
viewForm : Form -> Html Msg
viewForm form =
div []
[ input
[ type_ "text"
, placeholder "Name"
, value form.name
, onInput SetName
]
[]
, input
[ type_ "email"
, placeholder "Email"
, value form.email
, onInput SetEmail
]
[]
, button [ onClick Submit ] [ text "Submit" ]
, viewErrors form.errors
]
viewErrors : List String -> Html msg
viewErrors errors =
div []
(List.map (\error -> div [] [ text error ]) errors)
Routing
import Browser.Navigation as Nav
import Url
import Url.Parser as Parser exposing (Parser, (</>))
type Route
= Home
| Users
| User Int
| NotFound
routeParser : Parser (Route -> a) a
routeParser =
Parser.oneOf
[ Parser.map Home Parser.top
, Parser.map Users (Parser.s "users")
, Parser.map User (Parser.s "users" </> Parser.int)
]
fromUrl : Url.Url -> Route
fromUrl url =
Parser.parse routeParser url
|> Maybe.withDefault NotFound
-- In application
type alias Model =
{ key : Nav.Key
, route : Route
}
type Msg
= UrlChanged Url.Url
| LinkClicked Browser.UrlRequest
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked urlRequest ->
case urlRequest of
Browser.Internal url ->
( model, Nav.pushUrl model.key (Url.toString url) )
Browser.External href ->
( model, Nav.load href )
UrlChanged url ->
( { model | route = fromUrl url }, Cmd.none )
Elm Tooling
elm.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",
"elm/http": "2.0.0",
"elm/json": "1.1.3"
},
"indirect": {}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}
Commands
# Initialize new project
elm init
# Install package
elm install elm/http
# Build
elm make src/Main.elm
# Build optimized
elm make src/Main.elm --optimize --output=main.js
# Start development server
elm reactor
# Run tests
elm-test
# Format code
elm-format src/ --yes
# Review code
elm-review
Testing
Elm has a mature testing ecosystem with elm-test that emphasizes property-based testing (fuzz testing) alongside traditional unit tests. The strong type system eliminates many bugs, but tests remain valuable for logic and behavior verification.
Basic Unit Tests
module Tests exposing (..)
import Expect exposing (Expectation)
import Test exposing (Test, describe, test)
import MyModule exposing (add, parseAge)
-- Simple expectation tests
suite : Test
suite =
describe "MyModule"
[ describe "add"
[ test "adds two positive numbers" <|
\_ ->
add 2 3
|> Expect.equal 5
, test "adds negative numbers" <|
\_ ->
add (-2) 3
|> Expect.equal 1
, test "identity with zero" <|
\_ ->
add 0 5
|> Expect.equal 5
]
, describe "parseAge"
[ test "parses valid age" <|
\_ ->
parseAge "25"
|> Expect.equal (Ok 25)
, test "rejects negative age" <|
\_ ->
parseAge "-5"
|> Expect.err
, test "rejects non-numeric input" <|
\_ ->
parseAge "abc"
|> Expect.err
]
]
Fuzz Testing (Property-Based Testing)
import Fuzz exposing (Fuzzer, int, intRange, list, string)
import Test exposing (Test, describe, fuzz, fuzz2)
fuzzSuite : Test
fuzzSuite =
describe "Property-based tests"
[ fuzz int "add is commutative" <|
\x ->
add x 5
|> Expect.equal (add 5 x)
, fuzz2 int int "add is associative" <|
\x y ->
add x y
|> Expect.equal (add y x)
, fuzz (intRange 0 150) "valid ages are accepted" <|
\age ->
parseAge (String.fromInt age)
|> Expect.ok
, fuzz string "parseAge handles any string" <|
\str ->
-- Should never crash, always returns Result
case parseAge str of
Ok age ->
age |> Expect.atLeast 0
Err _ ->
Expect.pass
]
-- Custom Fuzzers
userFuzzer : Fuzzer User
userFuzzer =
Fuzz.map3 User
Fuzz.string -- name
(Fuzz.map (\s -> s ++ "@example.com") Fuzz.string) -- email
(Fuzz.intRange 0 120) -- age
userListFuzzer : Fuzzer (List User)
userListFuzzer =
Fuzz.list userFuzzer
Testing The Elm Architecture
import Test exposing (Test, describe, test)
import Main exposing (Model, Msg(..), update, init)
-- Test update function
updateTests : Test
updateTests =
describe "update"
[ test "Increment increases count" <|
\_ ->
let
( model, _ ) =
init ()
( newModel, _ ) =
update Increment model
in
newModel.count
|> Expect.equal 1
, test "Decrement decreases count" <|
\_ ->
let
model =
{ count = 5 }
( newModel, _ ) =
update Decrement model
in
newModel.count
|> Expect.equal 4
, test "Reset sets count to zero" <|
\_ ->
let
model =
{ count = 100 }
( newModel, _ ) =
update Reset model
in
newModel.count
|> Expect.equal 0
]
-- Test that update returns expected commands
commandTests : Test
commandTests =
describe "commands"
[ test "FetchUsers returns Http command" <|
\_ ->
let
( _, cmd ) =
update FetchUsers initialModel
in
cmd
|> Expect.notEqual Cmd.none
, test "Increment returns no command" <|
\_ ->
let
( _, cmd ) =
update Increment initialModel
in
cmd
|> Expect.equal Cmd.none
]
Testing JSON Decoders
import Json.Decode as Decode
import Test exposing (Test, describe, test)
import Expect
decoderTests : Test
decoderTests =
describe "JSON Decoders"
[ test "decodes valid user JSON" <|
\_ ->
let
json =
"""{"name": "Alice", "email": "alice@example.com", "age": 30}"""
in
Decode.decodeString userDecoder json
|> Expect.equal
(Ok { name = "Alice", email = "alice@example.com", age = 30 })
, test "fails on missing field" <|
\_ ->
let
json =
"""{"name": "Alice", "age": 30}"""
in
Decode.decodeString userDecoder json
|> Expect.err
, test "fails on wrong type" <|
\_ ->
let
json =
"""{"name": "Alice", "email": "alice@example.com", "age": "thirty"}"""
in
Decode.decodeString userDecoder json
|> Expect.err
, test "decodes list of users" <|
\_ ->
let
json =
"""[{"name": "Alice", "email": "a@b.com", "age": 30}]"""
in
Decode.decodeString (Decode.list userDecoder) json
|> Result.map List.length
|> Expect.equal (Ok 1)
]
Test Organization
# Recommended test structure
tests/
├── Tests.elm # Main test module, imports all
├── UnitTests/
│ ├── UserTests.elm # Tests for User module
│ ├── ApiTests.elm # Tests for API module
│ └── UtilTests.elm # Tests for utilities
└── FuzzTests/
└── PropertyTests.elm # Fuzz/property tests
-- tests/Tests.elm
module Tests exposing (suite)
import Test exposing (Test, describe)
import UnitTests.UserTests
import UnitTests.ApiTests
import FuzzTests.PropertyTests
suite : Test
suite =
describe "All Tests"
[ UnitTests.UserTests.suite
, UnitTests.ApiTests.suite
, FuzzTests.PropertyTests.suite
]
Running Tests
# Install elm-test
npm install -g elm-test
# Initialize tests in project
elm-test init
# Run all tests
elm-test
# Run tests in watch mode
elm-test --watch
# Run specific test file
elm-test tests/UserTests.elm
# Run with different seed (for fuzz tests)
elm-test --seed 12345
# Run with more fuzz iterations
elm-test --fuzz 1000
Expectation Helpers
import Expect exposing (Expectation)
-- Equality
Expect.equal expected actual
Expect.notEqual unexpected actual
-- Comparisons
Expect.lessThan upper actual
Expect.atMost upper actual
Expect.greaterThan lower actual
Expect.atLeast lower actual
-- Floating point (with tolerance)
Expect.within (Expect.Absolute 0.001) 3.14159 pi
-- Boolean
Expect.true "should be true" condition
Expect.false "should be false" condition
-- Result/Maybe
Expect.ok result -- Result is Ok
Expect.err result -- Result is Err
Expect.just maybe -- Maybe is Just (Elm 0.19+)
Expect.nothing maybe -- Maybe is Nothing
-- Lists
Expect.equalLists expected actual
-- Custom failure
Expect.fail "Custom failure message"
Expect.pass
What Tests Actually Catch in Elm
-- Types catch these (NO tests needed):
-- - Null pointer exceptions (no null in Elm)
-- - Type mismatches (compiler catches)
-- - Missing pattern matches (compiler catches)
-- - Undefined functions (compiler catches)
-- Tests catch these (tests valuable):
-- - Business logic errors
-- - Off-by-one errors
-- - Edge cases (empty lists, zero, negative numbers)
-- - JSON decode/encode round-trips
-- - Algorithm correctness
-- - State machine transitions (TEA update logic)
-- Example: Type system prevents this, no test needed
getName : User -> String -- Can NEVER be called with null
-- Example: Test needed for business logic
isEligible : User -> Bool
isEligible user =
user.age >= 18 && user.verified
-- Test: age=17, verified=true → false
-- Test: age=18, verified=false → false
-- Test: age=18, verified=true → true
Troubleshooting
Type Mismatch Errors
Problem: The 1st argument to map is not what I expect
-- Error: Wrong type
List.map String.toInt [ "1", "2", "3" ] -- Returns List (Maybe Int)
-- Fix: Handle Maybe
List.filterMap String.toInt [ "1", "2", "3" ] -- Returns List Int
Missing Pattern Match
Problem: This case expression does not have branches for all possibilities
-- Error: Missing Nothing case
getName : Maybe User -> String
getName maybeUser =
case maybeUser of
Just user ->
user.name
-- Fix: Add all cases
getName : Maybe User -> String
getName maybeUser =
case maybeUser of
Just user ->
user.name
Nothing ->
"Anonymous"
Circular Dependencies
Problem: IMPORT CYCLE
Fix: Extract shared types to separate module:
-- Before: Main.elm imports User.elm, User.elm imports Main.elm
-- After: Create Types.elm
module Types exposing (User, Msg)
-- Main.elm imports Types
-- User.elm imports Types
Concurrency
Elm takes a fundamentally different approach to concurrency compared to traditional imperative languages. Rather than managing threads and locks, Elm's architecture handles concurrency through managed effects. For cross-language comparison, see patterns-concurrency-dev.
Why Traditional Concurrency Doesn't Apply
-- Elm has NO:
-- - Threads or green threads
-- - Locks or mutexes
-- - Race conditions
-- - Shared mutable state
-- - Async/await keywords
-- Instead: The Elm Runtime manages ALL concurrency
-- Your code is ALWAYS single-threaded and synchronous
The Elm Architecture Handles Concurrency
-- Multiple HTTP requests "in flight" - runtime manages them
type Msg
= GotUser1 (Result Http.Error User)
| GotUser2 (Result Http.Error User)
| GotUser3 (Result Http.Error User)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
StartFetching ->
-- Runtime executes these concurrently
( { model | loading = True }
, Cmd.batch
[ Http.get { url = "/api/user/1", expect = Http.expectJson GotUser1 userDecoder }
, Http.get { url = "/api/user/2", expect = Http.expectJson GotUser2 userDecoder }
, Http.get { url = "/api/user/3", expect = Http.expectJson GotUser3 userDecoder }
]
)
GotUser1 result ->
-- Each response handled independently as it arrives
-- Runtime ensures update is never called concurrently
handleUserResult 1 result model
GotUser2 result ->
handleUserResult 2 result model
GotUser3 result ->
handleUserResult 3 result model
Cmd and Sub: Managed Effects
-- Cmd: Commands that produce effects
-- The runtime executes these, guarantees serialized updates
type alias Model =
{ time : Time.Posix
, windowSize : ( Int, Int )
}
type Msg
= Tick Time.Posix
| WindowResized Int Int
-- Subscriptions: Stream of events from outside world
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Time.every 1000 Tick -- Every second
, Browser.Events.onResize WindowResized -- On window resize
]
-- Runtime manages these subscriptions
-- Messages arrive one at a time in update function
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Tick newTime ->
( { model | time = newTime }, Cmd.none )
WindowResized width height ->
( { model | windowSize = ( width, height ) }, Cmd.none )
Task: Sequential Async Operations
import Task exposing (Task)
import Time
import Http
-- Task: Describes async operation, doesn't execute until performed
-- Think of it as a "recipe" for an async operation
getCurrentTime : Task Never Time.Posix
getCurrentTime =
Time.now
-- Chain Tasks sequentially
fetchAndLog : Task Http.Error String
fetchAndLog =
Http.task
{ method = "GET"
, headers = []
, url = "/api/data"
, body = Http.emptyBody
, resolver = Http.stringResolver handleResponse
, timeout = Nothing
}
|> Task.andThen (\data ->
-- Log happens AFTER fetch completes
Task.succeed ("Fetched: " ++ data)
)
-- Perform task to create Cmd
type Msg
= GotData (Result Http.Error String)
fetchData : Cmd Msg
fetchData =
Task.attempt GotData fetchAndLog
-- Task combinators for "concurrent" execution
-- (Runtime manages, you describe relationships)
fetchMultiple : Cmd Msg
fetchMultiple =
Task.map2 Tuple.pair
(Http.task { ... }) -- Fetch 1
(Http.task { ... }) -- Fetch 2
|> Task.attempt GotBothResults
-- Runtime may execute concurrently, delivers result when BOTH complete
Process: Background Tasks
import Process
import Task
-- Delay execution
delayed : Msg -> Cmd Msg
delayed msg =
Process.sleep 1000 -- 1 second in milliseconds
|> Task.perform (\_ -> msg)
-- Debouncing user input
type Msg
= UserTyped String
| DebouncedInput String
| CancelDebounce
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
UserTyped input ->
( { model | input = input }
, Cmd.batch
[ Process.sleep 500
|> Task.perform (\_ -> DebouncedInput input)
]
)
DebouncedInput input ->
-- Only fires if user stops typing for 500ms
( model, performSearch input )
Ports: Concurrent JavaScript Interop
-- port: Escape hatch for JS concurrency primitives
port module Main exposing (..)
-- Outgoing: Send to JavaScript
port sendToWorker : String -> Cmd msg
-- Incoming: Receive from JavaScript (subscription)
port workerResponse : (String -> msg) -> Sub msg
type Msg
= StartWork String
| WorkComplete String
subscriptions : Model -> Sub Msg
subscriptions model =
workerResponse WorkComplete
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
StartWork data ->
-- JavaScript can run Web Workers, handle concurrency
( { model | working = True }
, sendToWorker data
)
WorkComplete result ->
( { model | working = False, result = result }
, Cmd.none
)
-- In JavaScript:
-- app.ports.sendToWorker.subscribe(function(data) {
-- const worker = new Worker('worker.js');
-- worker.postMessage(data);
-- worker.onmessage = function(e) {
-- app.ports.workerResponse.send(e.data);
-- };
-- });
Key Principles
-- 1. Update is NEVER concurrent - always single-threaded
-- 2. Runtime manages ALL asynchronous operations
-- 3. No race conditions possible in Elm code
-- 4. Cmd/Sub provide declarative concurrency
-- 5. For CPU-intensive work: Use ports + Web Workers
-- Compare to other languages:
-- - Go/Erlang: Explicit goroutines/processes → Elm: Cmd/Sub
-- - JavaScript: async/await → Elm: Task
-- - Rust: threads + channels → Elm: Runtime + messages
Metaprogramming
Elm intentionally does not support traditional metaprogramming like macros or reflection. This is a deliberate design choice to ensure code is explicit, maintainable, and debuggable. For cross-language comparison, see patterns-metaprogramming-dev.
Why Elm Has No Metaprogramming
-- Elm has NO:
-- - Macros (no code that writes code)
-- - Reflection (can't inspect types at runtime)
-- - eval() (no runtime code execution)
-- - Dynamic code generation
-- - Preprocessor directives
-- Philosophy:
-- - Explicit is better than implicit
-- - Code should be readable without magic
-- - Compiler guarantees should be reliable
-- - Refactoring should be safe and predictable
Alternative: Code Generation (elm-codegen)
# External tool generates Elm code at build time
# NOT metaprogramming - generates source files you commit
# Example: Generate API client from OpenAPI spec
elm-codegen openapi.yaml --output src/Generated/Api.elm
# Result: Normal Elm code you can read and version control
-- Generated/Api.elm (example output)
module Generated.Api exposing (getUser, createUser)
import Http
import Json.Decode as Decode
getUser : Int -> (Result Http.Error User -> msg) -> Cmd msg
getUser userId toMsg =
Http.get
{ url = "/api/users/" ++ String.fromInt userId
, expect = Http.expectJson toMsg userDecoder
}
userDecoder : Decode.Decoder User
userDecoder =
Decode.map3 User
(Decode.field "name" Decode.string)
(Decode.field "email" Decode.string)
(Decode.field "age" Decode.int)
Alternative: Type-Driven Design
-- Instead of metaprogramming, use types to enforce invariants
-- Bad: Use strings, need validation everywhere
type alias UserId = String
validateUserId : String -> Maybe UserId
validateUserId str =
if String.startsWith "user-" str then
Just str
else
Nothing
-- Good: Make invalid states unrepresentable
type UserId = UserId String
createUserId : String -> Maybe UserId
createUserId str =
if String.startsWith "user-" str then
Just (UserId str)
else
Nothing
getUserById : UserId -> Cmd Msg
getUserById (UserId id) =
-- Guaranteed to be valid, no runtime checks needed
Http.get { url = "/api/users/" ++ id, ... }
-- Type system does the "metaprogramming" work at compile time
Alternative: Phantom Types
-- Encode state in types without runtime cost
type Validated
type Unvalidated
type Form a
= Form
{ email : String
, age : String
}
-- Can't use unvalidated form
submitForm : Form Validated -> Cmd Msg
submitForm (Form data) =
Http.post { ... }
-- Must validate first
validateForm : Form Unvalidated -> Result (List String) (Form Validated)
validateForm (Form data) =
if String.contains "@" data.email then
Ok (Form data)
else
Err [ "Invalid email" ]
-- Type system prevents: submitForm unvalidatedForm
-- Type system enforces: validateForm form |> Result.map submitForm
Alternative: Custom Types for DSLs
-- Instead of macros, define DSL as data
type Query
= Select (List String) Table (Maybe Condition)
| Insert Table (List ( String, Value ))
type Table = Table String
type Condition
= Equals String Value
| And Condition Condition
| Or Condition Condition
type Value
= StringVal String
| IntVal Int
-- Build queries as data
query : Query
query =
Select
[ "name", "email" ]
(Table "users")
(Just (Equals "active" (StringVal "true")))
-- Interpret DSL
toSql : Query -> String
toSql queryData =
case queryData of
Select fields (Table tableName) maybeWhere ->
"SELECT "
++ String.join ", " fields
++ " FROM "
++ tableName
++ (case maybeWhere of
Just condition ->
" WHERE " ++ conditionToSql condition
Nothing ->
""
)
_ ->
"..."
-- Result: Type-safe SQL without macros
-- Compiler checks all queries at compile time
Ports as FFI (Foreign Function Interface)
-- Port: Call JavaScript for things Elm can't do
port module Analytics exposing (trackEvent)
-- Send data to JavaScript
port trackEvent : { name : String, properties : Json.Encode.Value } -> Cmd msg
-- Use like any other Cmd
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ButtonClicked ->
( model
, trackEvent
{ name = "button_clicked"
, properties =
Json.Encode.object
[ ( "button_id", Json.Encode.string "submit" )
]
}
)
// JavaScript side (ports.js)
app.ports.trackEvent.subscribe(function(event) {
// Use any JS metaprogramming/reflection here
analytics.track(event.name, event.properties);
// Can use JS features Elm doesn't have:
// - eval()
// - Proxy objects
// - Reflect API
// - Dynamic code loading
});
elm-review for Custom Linting
-- Instead of macros, write custom compile-time checks
-- elm-review: Analyze and transform code at build time
module ReviewConfig exposing (config)
import Review.Rule as Rule
import Elm.Syntax.Expression exposing (Expression)
-- Custom rule: Prevent Debug.log in production
noDebugLog : Rule
noDebugLog =
Rule.newModuleRuleSchema "NoDebugLog" ()
|> Rule.withSimpleExpressionVisitor expressionVisitor
|> Rule.fromModuleRuleSchema
expressionVisitor : Expression -> List Rule.Error
expressionVisitor expression =
case expression of
FunctionOrValue [ "Debug" ] "log" ->
[ Rule.error
{ message = "Debug.log is not allowed"
, details = [ "Remove Debug.log before committing" ]
}
]
_ ->
[]
Key Principles
-- Elm philosophy on metaprogramming:
-- 1. No magic - code should be obvious
-- 2. Use types instead of runtime checks
-- 3. Generate code externally, commit it
-- 4. Ports for JS interop when needed
-- 5. elm-review for custom compile-time checks
-- Compare to other languages:
-- - Ruby/Lisp macros → Elm: Code generation + types
-- - Python reflection → Elm: Phantom types
-- - Template Haskell → Elm: External codegen
-- - C preprocessor → Elm: Type system + elm-review
Cross-Cutting Patterns
For cross-language comparison and translation patterns, see:
patterns-concurrency-dev- Compare Elm's Cmd/Sub/Task to threads, async/await, actorspatterns-metaprogramming-dev- Compare Elm's type-driven approach to macros, reflection, codegenpatterns-serialization-dev- JSON decoders/encoders patterns across languages
References
- Elm Guide - Official tutorial
- Elm Packages - Package repository
- Elm Discourse - Community forum
- Elm Radio Podcast - Elm news and discussions
- Elm in Action - Comprehensive book