Claude-skill-registry lang-elm-dev
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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/lang-elm-dev" ~/.claude/skills/majiayu000-claude-skill-registry-lang-elm-dev && rm -rf "$T"
skills/data/lang-elm-dev/SKILL.mdElm 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 | |
| Define union type | |
| Pattern match | |
| Handle Maybe | |
| Handle Result | |
| Update function | |
| View function | |
| HTTP request | |
| Decode JSON | |
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:
- Compare Elm's Cmd/Sub/Task to threads, async/await, actorspatterns-concurrency-dev
- Compare Elm's type-driven approach to macros, reflection, codegenpatterns-metaprogramming-dev
- JSON decoders/encoders patterns across languagespatterns-serialization-dev
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