From 7d7986f3abea5877e31e7165e2ad8a1b8f7dadd9 Mon Sep 17 00:00:00 2001 From: Mark Gerrard Date: Wed, 11 Mar 2026 11:07:15 +0000 Subject: [PATCH] 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 --- .gitignore | 23 + README.md | 122 ++++ exercises/01-hello-world/src/Main.elm | 25 + exercises/02-counter/src/Main.elm | 81 +++ exercises/03-temperature/src/Main.elm | 100 +++ lessons/01-introduction.md | 274 +++++++++ lessons/02-basics.md | 485 +++++++++++++++ lessons/03-functions.md | 498 +++++++++++++++ lessons/04-tea.md | 729 ++++++++++++++++++++++ lessons/05-lists-maybe.md | 533 ++++++++++++++++ lessons/06-http-json.md | 840 +++++++++++++++++++++++++ lessons/07-final-project.md | 856 ++++++++++++++++++++++++++ projects/task-manager/index.html | 197 ++++++ projects/task-manager/src/Main.elm | 370 +++++++++++ solutions/02-counter/src/Main.elm | 85 +++ solutions/03-temperature/src/Main.elm | 124 ++++ 16 files changed, 5342 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 exercises/01-hello-world/src/Main.elm create mode 100644 exercises/02-counter/src/Main.elm create mode 100644 exercises/03-temperature/src/Main.elm create mode 100644 lessons/01-introduction.md create mode 100644 lessons/02-basics.md create mode 100644 lessons/03-functions.md create mode 100644 lessons/04-tea.md create mode 100644 lessons/05-lists-maybe.md create mode 100644 lessons/06-http-json.md create mode 100644 lessons/07-final-project.md create mode 100644 projects/task-manager/index.html create mode 100644 projects/task-manager/src/Main.elm create mode 100644 solutions/02-counter/src/Main.elm create mode 100644 solutions/03-temperature/src/Main.elm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92f79f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Elm compilation output +elm-stuff/ +elm.js + +# Editor files +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.sublime-* + +# OS files +.DS_Store +Thumbs.db + +# Node (if using npm for tooling) +node_modules/ +package-lock.json + +# Build artifacts +/build/ +/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe89079 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Elm Tutorial for JavaScript Developers + +Welcome! This tutorial is designed for developers with JavaScript experience who want to learn Elm. We'll leverage your existing knowledge while introducing functional programming concepts gradually. + +## What is Elm? + +Elm is a functional programming language that compiles to JavaScript. It's known for: + +- **No runtime exceptions** - The compiler catches errors before your code runs +- **Friendly error messages** - Best-in-class compiler messages that help you fix issues +- **Enforced semantic versioning** - Package updates never break your code unexpectedly +- **Small bundle sizes** - Elm produces highly optimized JavaScript + +## Why Learn Elm? + +As a JavaScript developer, Elm will teach you: + +1. Pure functional programming patterns +2. Strong static typing (that actually helps rather than hinders) +3. Immutable data structures +4. A clean architecture pattern (The Elm Architecture inspired Redux!) + +## Prerequisites + +- Basic JavaScript knowledge +- Familiarity with HTML +- A code editor (VS Code recommended with the Elm extension) + +## Setup + +### 1. Install Elm + +**macOS (Homebrew):** +```bash +brew install elm +``` + +**Linux/macOS (npm):** +```bash +npm install -g elm +``` + +**Windows:** +Download the installer from [elm-lang.org](https://guide.elm-lang.org/install/elm.html) + +### 2. Verify Installation + +```bash +elm --version +# Should output something like: 0.19.1 +``` + +### 3. Install Helpful Tools + +```bash +# Elm formatter (like Prettier for Elm) +npm install -g elm-format + +# Elm test runner +npm install -g elm-test + +# Development server with hot reloading +npm install -g elm-live +``` + +### 4. Editor Setup + +For VS Code, install the "Elm" extension by Elm tooling. This gives you: +- Syntax highlighting +- Auto-formatting on save +- Inline error messages +- Go to definition + +## Tutorial Structure + +| Lesson | Topic | Key Concepts | +|--------|-------|--------------| +| 1 | [Introduction & First Program](lessons/01-introduction.md) | REPL, Hello World, elm.json | +| 2 | [Basic Syntax & Types](lessons/02-basics.md) | Values, Types, Type Annotations | +| 3 | [Functions](lessons/03-functions.md) | Pure Functions, Currying, Pipes | +| 4 | [The Elm Architecture](lessons/04-tea.md) | Model, View, Update | +| 5 | [Lists & Maybe](lessons/05-lists-maybe.md) | Collections, Null Safety | +| 6 | [HTTP & JSON](lessons/06-http-json.md) | Commands, Decoders | +| 7 | [Final Project](lessons/07-final-project.md) | Building a Complete App | + +## How to Use This Tutorial + +1. **Read each lesson** in order - concepts build on each other +2. **Type the code yourself** - don't just copy-paste +3. **Complete the exercises** - they reinforce learning +4. **Experiment** - break things, see what errors you get + +## Quick Reference: JavaScript to Elm + +Here's a preview of how familiar JavaScript concepts translate to Elm: + +```javascript +// JavaScript +const name = "Alice"; +const add = (a, b) => a + b; +const numbers = [1, 2, 3]; +const doubled = numbers.map(n => n * 2); +``` + +```elm +-- Elm +name = "Alice" +add a b = a + b +numbers = [1, 2, 3] +doubled = List.map (\n -> n * 2) numbers +``` + +## Getting Help + +- [Official Elm Guide](https://guide.elm-lang.org/) +- [Elm Slack](https://elmlang.herokuapp.com/) +- [Elm Discourse](https://discourse.elm-lang.org/) +- [Elm Packages](https://package.elm-lang.org/) + +--- + +Ready? Let's start with [Lesson 1: Introduction](lessons/01-introduction.md)! diff --git a/exercises/01-hello-world/src/Main.elm b/exercises/01-hello-world/src/Main.elm new file mode 100644 index 0000000..e7dd7bd --- /dev/null +++ b/exercises/01-hello-world/src/Main.elm @@ -0,0 +1,25 @@ +module Main exposing (main) + +{-| Exercise 1: Hello World + +Complete the exercises below by modifying this file. + +Exercise 1.1: Change the greeting to say "Hello, [Your Name]!" +Exercise 1.2: Make the greeting ALL CAPS using String.toUpper +Exercise 1.3: Add a second paragraph with your programming experience + +Run with: elm reactor +Then open http://localhost:8000/src/Main.elm + +-} + +import Html exposing (Html, div, h1, p, text) + + +main : Html msg +main = + div [] + [ h1 [] [ text "Hello, Elm!" ] + + -- Add more elements here for Exercise 1.3 + ] diff --git a/exercises/02-counter/src/Main.elm b/exercises/02-counter/src/Main.elm new file mode 100644 index 0000000..7269eba --- /dev/null +++ b/exercises/02-counter/src/Main.elm @@ -0,0 +1,81 @@ +module Main exposing (main) + +{-| Exercise 2: Counter + +This is a basic counter application. Your tasks: + +Exercise 2.1: Add a "Reset" button that sets the count back to 0 +Exercise 2.2: Add a "Double" button that doubles the current count +Exercise 2.3: Prevent the count from going below 0 + +Run with: elm reactor +Then open http://localhost:8000/src/Main.elm + +-} + +import Browser +import Html exposing (Html, button, div, text) +import Html.Events exposing (onClick) + + + +-- MODEL + + +type alias Model = + { count : Int + } + + +init : Model +init = + { count = 0 + } + + + +-- UPDATE + + +type Msg + = Increment + | Decrement + -- Add more messages here for the exercises + + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + Decrement -> + { model | count = model.count - 1 } + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [] + [ button [ onClick Decrement ] [ text "-" ] + , div [] [ text (String.fromInt model.count) ] + , button [ onClick Increment ] [ text "+" ] + + -- Add more buttons here + ] + + + +-- MAIN + + +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , update = update + , view = view + } diff --git a/exercises/03-temperature/src/Main.elm b/exercises/03-temperature/src/Main.elm new file mode 100644 index 0000000..b3aeb85 --- /dev/null +++ b/exercises/03-temperature/src/Main.elm @@ -0,0 +1,100 @@ +module Main exposing (main) + +{-| Exercise 3: Temperature Converter + +Build a temperature converter: + +Exercise 3.1: Convert Celsius to Fahrenheit (F = C × 9/5 + 32) +Exercise 3.2: Add Fahrenheit to Celsius conversion +Exercise 3.3: Handle invalid input gracefully + +Run with: elm reactor +Then open http://localhost:8000/src/Main.elm + +-} + +import Browser +import Html exposing (Html, div, input, text) +import Html.Attributes exposing (placeholder, value) +import Html.Events exposing (onInput) + + + +-- MODEL + + +type alias Model = + { celsius : String + } + + +init : Model +init = + { celsius = "" + } + + + +-- UPDATE + + +type Msg + = UpdateCelsius String + + +update : Msg -> Model -> Model +update msg model = + case msg of + UpdateCelsius value -> + { model | celsius = value } + + + +-- HELPER FUNCTIONS + + +celsiusToFahrenheit : Float -> Float +celsiusToFahrenheit c = + -- TODO: Implement the conversion formula + 0 + + + +-- VIEW + + +view : Model -> Html Msg +view model = + let + fahrenheit = + case String.toFloat model.celsius of + Just c -> + -- TODO: Call celsiusToFahrenheit and format the result + "TODO" + + Nothing -> + "Enter a valid number" + in + div [] + [ input + [ placeholder "Enter Celsius" + , value model.celsius + , onInput UpdateCelsius + ] + [] + , text " °C = " + , text fahrenheit + ] + + + +-- MAIN + + +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , update = update + , view = view + } diff --git a/lessons/01-introduction.md b/lessons/01-introduction.md new file mode 100644 index 0000000..e1d2a4b --- /dev/null +++ b/lessons/01-introduction.md @@ -0,0 +1,274 @@ +# Lesson 1: Introduction & Your First Elm Program + +## Learning Goals + +By the end of this lesson, you will: +- Understand the Elm REPL +- Create your first Elm project +- Compile and run Elm code +- Understand the project structure + +## The Elm REPL + +Like Node.js, Elm has a REPL (Read-Eval-Print Loop) for experimenting with code. + +Start it by running: +```bash +elm repl +``` + +Try these expressions: + +```elm +> 1 + 1 +2 : number + +> "Hello" ++ " World" +"Hello World" : String + +> String.length "Elm" +3 : Int +``` + +Notice something different from JavaScript? Elm tells you the **type** of each result! + +### Key Differences from JavaScript Console + +| JavaScript | Elm REPL | +|-----------|----------| +| `"a" + "b"` | `"a" ++ "b"` (string concat uses `++`) | +| `1 + "1"` = `"11"` | Error! Can't mix types | +| `console.log(x)` | Just type the expression | + +Type `:exit` to leave the REPL. + +## Your First Elm Project + +Let's create a real project: + +```bash +mkdir hello-elm +cd hello-elm +elm init +``` + +This creates: +``` +hello-elm/ +├── elm.json # Project configuration (like package.json) +└── src/ # Your Elm source files go here +``` + +### Understanding elm.json + +```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" + }, + "indirect": { ... } + }, + "test-dependencies": { ... } +} +``` + +**Comparison to JavaScript:** +- `source-directories` is like setting up your bundler's entry points +- `dependencies` works like `package.json` but with direct/indirect separation +- Unlike npm, Elm enforces **semantic versioning automatically** + +## Hello World + +Create `src/Main.elm`: + +```elm +module Main exposing (main) + +import Html exposing (text) + + +main = + text "Hello, Elm!" +``` + +Let's break this down: + +### 1. Module Declaration +```elm +module Main exposing (main) +``` +- Every Elm file is a **module** +- `exposing (main)` says "make `main` available to other modules" +- **JavaScript equivalent:** `export { main }` + +### 2. Imports +```elm +import Html exposing (text) +``` +- Import the `Html` module +- `exposing (text)` lets us use `text` directly instead of `Html.text` +- **JavaScript equivalent:** `import { text } from 'html'` + +### 3. The main Function +```elm +main = + text "Hello, Elm!" +``` +- `main` is the entry point (like `ReactDOM.render` or your app's root) +- `text` creates an HTML text node +- No `return` keyword needed - the last expression is the return value + +## Running Your Code + +### Option 1: Elm Reactor (Development Server) + +```bash +elm reactor +``` + +Open http://localhost:8000 and click on `src/Main.elm`. + +### Option 2: Compile to HTML + +```bash +elm make src/Main.elm +``` + +This creates an `index.html` file you can open in a browser. + +### Option 3: Elm Live (Hot Reloading) + +```bash +elm-live src/Main.elm -- --open +``` + +This opens your browser and reloads on file changes (like webpack-dev-server). + +## Exercise 1.1: Experiment in the REPL + +Start `elm repl` and try: + +1. Calculate `123 * 456` +2. Concatenate three strings: "I", " love", " Elm" +3. Find the length of "functional programming" +4. Try `1 + "hello"` - what happens? + +
+Solutions + +```elm +> 123 * 456 +56088 : number + +> "I" ++ " love" ++ " Elm" +"I love Elm" : String + +> String.length "functional programming" +22 : Int + +> 1 + "hello" +-- TYPE MISMATCH -- +The (+) operator is expecting both arguments to be numbers... +``` + +
+ +## Exercise 1.2: Modify Hello World + +1. Change the greeting to include your name +2. Try using `String.toUpper` to make it ALL CAPS + +
+Solution + +```elm +module Main exposing (main) + +import Html exposing (text) + + +main = + text (String.toUpper "Hello, Your Name!") +``` + +
+ +## Exercise 1.3: Multiple Elements + +We need to use `Html.div` to group multiple elements: + +```elm +module Main exposing (main) + +import Html exposing (div, h1, p, text) + + +main = + div [] + [ h1 [] [ text "Welcome to Elm" ] + , p [] [ text "This is a paragraph" ] + ] +``` + +The pattern is: `element attributes children` +- `div []` - a div with no attributes +- `[ h1 ..., p ... ]` - list of children + +Create a page with: +1. A heading with your name +2. A paragraph about why you're learning Elm +3. Another paragraph about your programming background + +
+Solution + +```elm +module Main exposing (main) + +import Html exposing (div, h1, p, text) + + +main = + div [] + [ h1 [] [ text "Hi, I'm Learning Elm!" ] + , p [] [ text "I want to learn functional programming." ] + , p [] [ text "I have experience with JavaScript." ] + ] +``` + +
+ +## Key Takeaways + +1. **Elm is typed** - The compiler tells you types and catches errors early +2. **No semicolons or braces** - Whitespace and indentation matter +3. **Everything is an expression** - No statements, everything returns a value +4. **String concatenation uses `++`** - Not `+` like JavaScript +5. **HTML is just functions** - `div`, `h1`, `p` are all functions + +## Common Mistakes from JavaScript Developers + +| Mistake | Why It Happens | Elm Way | +|---------|---------------|---------| +| Using `+` for strings | JavaScript habit | Use `++` | +| Forgetting type annotations | JS doesn't have them | Optional but recommended | +| Using `return` | JS function habit | Last expression is returned | +| Using `{}` for blocks | JS syntax | Use indentation | + +## What's Next? + +In [Lesson 2](02-basics.md), we'll dive deeper into Elm's type system and learn about: +- Primitive types (Int, Float, String, Bool) +- Type annotations +- Records (like JavaScript objects, but better!) +- Custom types + +--- + +[Next: Lesson 2 - Basic Syntax & Types →](02-basics.md) diff --git a/lessons/02-basics.md b/lessons/02-basics.md new file mode 100644 index 0000000..8a32060 --- /dev/null +++ b/lessons/02-basics.md @@ -0,0 +1,485 @@ +# Lesson 2: Basic Syntax & Types + +## Learning Goals + +By the end of this lesson, you will: +- Understand Elm's primitive types +- Write type annotations +- Work with records (Elm's "objects") +- Create type aliases +- Understand union types (a powerful feature JS doesn't have!) + +## Primitive Types + +### Numbers + +```elm +-- Integers +age : Int +age = 30 + +-- Floating point +price : Float +price = 19.99 + +-- The 'number' type works with both +double : number -> number +double n = n * 2 + +double 5 -- 10 : number +double 5.5 -- 11.0 : Float +``` + +**JavaScript comparison:** +```javascript +// JavaScript - just "number" +const age = 30; +const price = 19.99; +``` + +### Strings + +```elm +name : String +name = "Alice" + +-- Multi-line strings use triple quotes +poem : String +poem = """ +Roses are red, +Violets are blue, +Elm has no nulls, +And neither should you! +""" + +-- String operations +String.length "hello" -- 5 +String.reverse "hello" -- "olleh" +String.toUpper "hello" -- "HELLO" +"Hello" ++ " World" -- "Hello World" +``` + +**JavaScript comparison:** +```javascript +const name = "Alice"; +const poem = ` +Roses are red... +`; +"Hello" + " World" // Uses + not ++ +``` + +### Booleans + +```elm +isActive : Bool +isActive = True + +-- Note: True and False are capitalized! +-- Comparison operators +5 > 3 -- True +5 == 5 -- True (equality uses ==, same as JS) +5 /= 3 -- True (not equal uses /= not !==) + +-- Boolean operators +True && False -- False +True || False -- True +not True -- False +``` + +**Key difference:** Not-equal is `/=` in Elm, not `!==` like JavaScript. + +### Characters + +```elm +-- Single characters use single quotes +letter : Char +letter = 'A' + +-- Strings use double quotes +word : String +word = "ABC" +``` + +JavaScript doesn't have a separate character type. + +## Type Annotations + +Type annotations are **optional** but highly recommended: + +```elm +-- Without annotation (Elm infers the type) +add a b = a + b + +-- With annotation (clearer and self-documenting) +add : Int -> Int -> Int +add a b = a + b +``` + +### Reading Type Annotations + +```elm +greet : String -> String +-- ^input ^output +greet name = "Hello, " ++ name + +add : Int -> Int -> Int +-- ^first ^second ^result +add a b = a + b +``` + +Think of `->` as "then returns". So `Int -> Int -> Int` reads as: +"Takes an Int, then an Int, then returns an Int" + +## Records: Elm's Objects + +Records are like JavaScript objects, but **immutable** and **typed**. + +```elm +-- Defining a record +person = + { name = "Alice" + , age = 30 + , email = "alice@example.com" + } + +-- Accessing fields (dot notation, like JS) +person.name -- "Alice" +person.age -- 30 + +-- Or using accessor functions +.name person -- "Alice" +``` + +### Record Type Annotations + +```elm +-- Inline type +alice : { name : String, age : Int } +alice = { name = "Alice", age = 30 } + +-- With type alias (preferred for reuse) +type alias Person = + { name : String + , age : Int + , email : String + } + +bob : Person +bob = + { name = "Bob" + , age = 25 + , email = "bob@example.com" + } +``` + +### Updating Records + +In Elm, you can't mutate records. Instead, you create a new one: + +```elm +-- JavaScript way (mutates) +-- person.age = 31; + +-- Elm way (creates new record) +olderPerson = { person | age = 31 } + +-- Update multiple fields +updatedPerson = + { person + | age = 31 + , email = "newemail@example.com" + } +``` + +The original `person` is unchanged! This is **immutability**. + +### Why Immutability Matters + +```javascript +// JavaScript - Mutation causes bugs +const user = { name: "Alice", score: 100 }; +doSomething(user); +console.log(user.score); // Who knows? doSomething might have changed it! +``` + +```elm +-- Elm - No surprises +user = { name = "Alice", score = 100 } +newUser = doSomething user +user.score -- Still 100, guaranteed! +newUser.score -- Whatever doSomething returned +``` + +## Type Aliases + +Type aliases give names to types: + +```elm +type alias Person = + { name : String + , age : Int + } + +type alias Point = + { x : Float + , y : Float + } + +-- Use them in annotations +distance : Point -> Point -> Float +distance p1 p2 = + sqrt ((p2.x - p1.x)^2 + (p2.y - p1.y)^2) +``` + +Type aliases also create **constructor functions**: + +```elm +type alias Person = + { name : String + , age : Int + } + +-- This automatically creates: +-- Person : String -> Int -> Person + +alice = Person "Alice" 30 +-- Same as: { name = "Alice", age = 30 } +``` + +## Custom Types (Union Types) + +This is where Elm gets really powerful. Custom types let you define your own types with specific variants: + +```elm +type Color + = Red + | Green + | Blue + +type Status + = Loading + | Success String + | Error String +``` + +### Using Custom Types + +```elm +type Status + = Loading + | Success String + | Error String + +showStatus : Status -> String +showStatus status = + case status of + Loading -> + "Loading..." + + Success message -> + "Success: " ++ message + + Error errorMsg -> + "Error: " ++ errorMsg + +showStatus Loading -- "Loading..." +showStatus (Success "Done!") -- "Success: Done!" +showStatus (Error "Not found") -- "Error: Not found" +``` + +### JavaScript Comparison + +```javascript +// JavaScript - using strings (error-prone) +const status = "loading"; +if (status === "loading") { ... } +if (status === "laoding") { ... } // Typo! No error + +// JavaScript - using objects +const status = { type: "success", message: "Done" }; +``` + +```elm +-- Elm - compiler catches typos +case status of + Laoding -> ... -- COMPILE ERROR: Laoding is not defined +``` + +### Maybe: Handling Missing Values + +Elm has no `null` or `undefined`. Instead, it uses the `Maybe` type: + +```elm +type Maybe a + = Just a + | Nothing + +-- Example: Safe dictionary lookup +Dict.get "name" myDict -- Returns Maybe String + +-- You MUST handle both cases +case Dict.get "name" myDict of + Just value -> + "Found: " ++ value + + Nothing -> + "Not found" +``` + +**This eliminates null pointer exceptions entirely!** + +## Exercise 2.1: Define a Record Type + +Create a `type alias` for a `Book` with: +- title (String) +- author (String) +- pages (Int) +- isRead (Bool) + +Then create two book records. + +
+Solution + +```elm +type alias Book = + { title : String + , author : String + , pages : Int + , isRead : Bool + } + +book1 : Book +book1 = + { title = "The Elm Guide" + , author = "Evan Czaplicki" + , pages = 150 + , isRead = True + } + +book2 : Book +book2 = Book "Learn You a Haskell" "Miran Lipovaca" 400 False +``` + +
+ +## Exercise 2.2: Update a Record + +Given this record: + +```elm +player = + { name = "Hero" + , health = 100 + , score = 0 + } +``` + +Create a new record where: +1. health is reduced to 80 +2. score is increased to 50 + +
+Solution + +```elm +updatedPlayer = + { player + | health = 80 + , score = 50 + } +``` + +
+ +## Exercise 2.3: Create a Custom Type + +Create a `TrafficLight` type with Red, Yellow, and Green variants. +Write a function `canGo : TrafficLight -> Bool` that returns True only for Green. + +
+Solution + +```elm +type TrafficLight + = Red + | Yellow + | Green + + +canGo : TrafficLight -> Bool +canGo light = + case light of + Green -> + True + + Yellow -> + False + + Red -> + False + +-- Or more concisely: +canGo2 : TrafficLight -> Bool +canGo2 light = + light == Green +``` + +
+ +## Exercise 2.4: Maybe Practice + +Write a function that takes a `Maybe Int` and returns the value doubled, or 0 if Nothing: + +```elm +doubleOrZero : Maybe Int -> Int +``` + +
+Solution + +```elm +doubleOrZero : Maybe Int -> Int +doubleOrZero maybeNum = + case maybeNum of + Just n -> + n * 2 + + Nothing -> + 0 +``` + +
+ +## Key Takeaways + +1. **Elm has distinct Int and Float types** - Unlike JavaScript's single "number" +2. **Type annotations are documentation** - They make code self-explanatory +3. **Records are immutable** - Use update syntax `{ record | field = value }` +4. **Type aliases** create reusable type names AND constructor functions +5. **Custom types** replace string constants and are type-safe +6. **Maybe replaces null** - Forces you to handle missing values explicitly + +## JavaScript to Elm Cheatsheet + +| JavaScript | Elm | +|-----------|-----| +| `{}` | Record `{ field = value }` | +| `obj.field` | Same: `record.field` | +| `{...obj, field: newVal}` | `{ record \| field = newVal }` | +| `null` / `undefined` | `Maybe` type | +| String constants | Custom types | +| `===` | `==` | +| `!==` | `/=` | + +## What's Next? + +In [Lesson 3](03-functions.md), we'll explore: +- Pure functions +- Higher-order functions +- Currying and partial application +- The pipe operator + +--- + +[← Previous: Lesson 1](01-introduction.md) | [Next: Lesson 3 - Functions →](03-functions.md) diff --git a/lessons/03-functions.md b/lessons/03-functions.md new file mode 100644 index 0000000..62c51ac --- /dev/null +++ b/lessons/03-functions.md @@ -0,0 +1,498 @@ +# Lesson 3: Functions + +## Learning Goals + +By the end of this lesson, you will: +- Write pure functions +- Understand currying and partial application +- Use higher-order functions (map, filter, reduce) +- Use the pipe operator for readable code +- Understand function composition + +## Defining Functions + +### Basic Syntax + +```elm +-- JavaScript +// const greet = (name) => "Hello, " + name; + +-- Elm +greet : String -> String +greet name = + "Hello, " ++ name +``` + +Key differences: +- No parentheses around parameters +- No `return` keyword +- No curly braces +- Last expression is automatically returned + +### Multiple Parameters + +```elm +-- JavaScript +// const add = (a, b) => a + b; + +-- Elm +add : Int -> Int -> Int +add a b = + a + b + +-- Using it +add 5 3 -- 8 (no parentheses or commas!) +``` + +### If-Then-Else + +```elm +-- JavaScript +// const abs = x => x < 0 ? -x : x; + +-- Elm +abs : Int -> Int +abs x = + if x < 0 then + -x + else + x +``` + +**Important:** In Elm, `if` is an **expression** that returns a value, not a statement. Both branches must return the same type. + +```elm +-- This won't compile: +invalid x = + if x > 0 then + "positive" + -- Missing else! What would it return? +``` + +## Pure Functions + +In Elm, ALL functions are **pure**: + +1. **Same input → Same output** (always) +2. **No side effects** (no I/O, no mutation, no randomness) + +```javascript +// JavaScript - Impure function +let counter = 0; +function increment() { + counter += 1; // Side effect: modifies external state + return counter; +} +increment(); // 1 +increment(); // 2 - Different result! +``` + +```elm +-- Elm - Pure function +increment : Int -> Int +increment counter = + counter + 1 + +increment 0 -- 1 +increment 0 -- 1 (always!) +``` + +### Benefits of Pure Functions + +1. **Testable** - No mocks needed, just input/output +2. **Cacheable** - Same input = same output, so cache results +3. **Predictable** - No hidden state changes +4. **Parallelizable** - Safe to run simultaneously + +## Currying and Partial Application + +### What is Currying? + +In Elm, every function takes exactly one argument. A function with "multiple arguments" is actually a chain of functions. + +```elm +add : Int -> Int -> Int +add a b = a + b + +-- This is actually: +add : Int -> (Int -> Int) +-- A function that takes an Int and returns a function that takes an Int +``` + +### Partial Application + +You can apply some arguments now and the rest later: + +```elm +add : Int -> Int -> Int +add a b = a + b + +addFive : Int -> Int +addFive = add 5 -- Partially applied! + +addFive 3 -- 8 +addFive 10 -- 15 +``` + +**JavaScript equivalent:** +```javascript +const add = a => b => a + b; +const addFive = add(5); +addFive(3); // 8 +``` + +### Practical Example + +```elm +-- A formatting function +format : String -> String -> String +format prefix text = + prefix ++ ": " ++ text + +-- Create specialized formatters +formatError : String -> String +formatError = format "ERROR" + +formatWarning : String -> String +formatWarning = format "WARNING" + +formatError "File not found" -- "ERROR: File not found" +formatWarning "Low memory" -- "WARNING: Low memory" +``` + +## Anonymous Functions (Lambdas) + +```elm +-- JavaScript +// const double = x => x * 2; +// numbers.map(x => x * 2); + +-- Elm +double = \x -> x * 2 +List.map (\x -> x * 2) numbers +``` + +The `\` represents the Greek letter lambda (λ). + +### Multiple Parameters + +```elm +-- JavaScript +// const add = (a, b) => a + b; + +-- Elm +add = \a b -> a + b +-- Or equivalently +add = \a -> \b -> a + b +``` + +## Higher-Order Functions + +Higher-order functions take or return other functions. + +### List.map (like Array.map) + +```elm +-- JavaScript +// [1, 2, 3].map(x => x * 2) + +-- Elm +List.map (\x -> x * 2) [1, 2, 3] -- [2, 4, 6] + +-- Or with a named function +double x = x * 2 +List.map double [1, 2, 3] -- [2, 4, 6] +``` + +### List.filter (like Array.filter) + +```elm +-- JavaScript +// [1, 2, 3, 4, 5].filter(x => x > 3) + +-- Elm +List.filter (\x -> x > 3) [1, 2, 3, 4, 5] -- [4, 5] +``` + +### List.foldl / List.foldr (like Array.reduce) + +```elm +-- JavaScript +// [1, 2, 3].reduce((sum, x) => sum + x, 0) + +-- Elm +List.foldl (\x sum -> sum + x) 0 [1, 2, 3] -- 6 + +-- Or using the (+) operator as a function +List.foldl (+) 0 [1, 2, 3] -- 6 +``` + +Note: Arguments are in different order than JavaScript! +- `foldl` goes left-to-right +- `foldr` goes right-to-left + +### Other Useful Functions + +```elm +List.head [1, 2, 3] -- Just 1 (returns Maybe!) +List.tail [1, 2, 3] -- Just [2, 3] +List.take 2 [1, 2, 3, 4] -- [1, 2] +List.drop 2 [1, 2, 3, 4] -- [3, 4] +List.reverse [1, 2, 3] -- [3, 2, 1] +List.sort [3, 1, 2] -- [1, 2, 3] +List.member 2 [1, 2, 3] -- True +List.length [1, 2, 3] -- 3 +``` + +## The Pipe Operator |> + +The pipe operator makes chains of functions readable: + +```elm +-- Without pipes (hard to read, inside-out) +String.toUpper (String.trim (String.reverse " hello ")) + +-- With pipes (clear data flow) +" hello " + |> String.reverse + |> String.trim + |> String.toUpper +-- Result: "OLLEH" +``` + +**How it works:** `x |> f` is the same as `f x` + +### JavaScript Comparison + +```javascript +// JavaScript method chaining (only works with methods) +" hello ".split("").reverse().join("").trim().toUpperCase() + +// JavaScript pipe (proposed, not standard) +" hello " |> reverse |> trim |> toUpperCase +``` + +### Complex Example + +```elm +-- Get the names of users over 21, sorted +users + |> List.filter (\user -> user.age > 21) + |> List.map .name + |> List.sort +``` + +## Function Composition + +Combine functions into new functions: + +```elm +-- The >> operator (left to right) +addOne = (+) 1 +double = (*) 2 + +addOneThenDouble : Int -> Int +addOneThenDouble = addOne >> double + +addOneThenDouble 5 -- 12 (5+1=6, 6*2=12) + +-- The << operator (right to left) +doubleThenAddOne : Int -> Int +doubleThenAddOne = addOne << double + +doubleThenAddOne 5 -- 11 (5*2=10, 10+1=11) +``` + +### Pipe vs Composition + +```elm +-- Pipe: Apply to a specific value +5 |> addOne |> double -- 12 + +-- Composition: Create a new function +addOneThenDouble = addOne >> double +addOneThenDouble 5 -- 12 +``` + +## Let Expressions + +Use `let...in` for local bindings: + +```elm +-- JavaScript +// function circleArea(radius) { +// const pi = 3.14159; +// const squared = radius * radius; +// return pi * squared; +// } + +-- Elm +circleArea : Float -> Float +circleArea radius = + let + pi = 3.14159 + squared = radius * radius + in + pi * squared +``` + +You can also define helper functions: + +```elm +pythagoras : Float -> Float -> Float +pythagoras a b = + let + square x = x * x + in + sqrt (square a + square b) +``` + +## Exercise 3.1: Basic Functions + +Write these functions: + +1. `isEven : Int -> Bool` - Returns True if number is even +2. `exclaim : String -> String` - Adds "!" to the end of a string +3. `greet : String -> String -> String` - Takes a greeting and a name + +
+Solution + +```elm +isEven : Int -> Bool +isEven n = + modBy 2 n == 0 + + +exclaim : String -> String +exclaim text = + text ++ "!" + + +greet : String -> String -> String +greet greeting name = + greeting ++ ", " ++ name ++ "!" +``` + +
+ +## Exercise 3.2: Higher-Order Functions + +Given this list: +```elm +numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +``` + +Use List functions to: +1. Get only the odd numbers +2. Square each number +3. Get the sum of all numbers + +
+Solution + +```elm +-- 1. Odd numbers +List.filter (\n -> modBy 2 n /= 0) numbers +-- [1, 3, 5, 7, 9] + +-- 2. Squared +List.map (\n -> n * n) numbers +-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] + +-- 3. Sum +List.foldl (+) 0 numbers +-- Or: List.sum numbers +-- 55 +``` + +
+ +## Exercise 3.3: Pipe Operator + +Rewrite this using the pipe operator: + +```elm +String.fromInt (List.length (List.filter isEven (List.range 1 100))) +``` + +
+Solution + +```elm +List.range 1 100 + |> List.filter isEven + |> List.length + |> String.fromInt +-- "50" +``` + +
+ +## Exercise 3.4: Partial Application + +Create a `greetUser` function by partially applying this: + +```elm +greet : String -> String -> String +greet greeting name = + greeting ++ ", " ++ name ++ "!" +``` + +So that `greetUser "Alice"` returns `"Hello, Alice!"` + +
+Solution + +```elm +greetUser : String -> String +greetUser = greet "Hello" + +greetUser "Alice" -- "Hello, Alice!" +greetUser "Bob" -- "Hello, Bob!" +``` + +
+ +## Key Takeaways + +1. **All Elm functions are pure** - Same input always gives same output +2. **All functions are curried** - Multi-arg functions are chains of single-arg functions +3. **Partial application** lets you create specialized functions +4. **The pipe operator `|>`** makes data transformation chains readable +5. **Higher-order functions** like `map`, `filter`, `foldl` are your primary tools +6. **`let...in`** provides local bindings within functions + +## Common Patterns + +```elm +-- Transform a list +items |> List.map transform + +-- Filter and transform +items + |> List.filter condition + |> List.map transform + +-- Find something +items + |> List.filter condition + |> List.head + +-- Aggregate +items + |> List.foldl combiner initial +``` + +## What's Next? + +In [Lesson 4](04-tea.md), we'll learn The Elm Architecture (TEA): +- Model: Your application state +- View: Turning state into HTML +- Update: Handling messages and updating state + +This is the heart of Elm applications and inspired Redux! + +--- + +[← Previous: Lesson 2](02-basics.md) | [Next: Lesson 4 - The Elm Architecture →](04-tea.md) diff --git a/lessons/04-tea.md b/lessons/04-tea.md new file mode 100644 index 0000000..e55eb34 --- /dev/null +++ b/lessons/04-tea.md @@ -0,0 +1,729 @@ +# Lesson 4: The Elm Architecture (TEA) + +## Learning Goals + +By the end of this lesson, you will: +- Understand the Model-View-Update pattern +- Handle user input events +- Build interactive applications +- Understand how Elm inspired Redux + +## What is The Elm Architecture? + +The Elm Architecture (TEA) is a pattern for building web applications. It has three parts: + +1. **Model** - Your application's state (the data) +2. **View** - A function that turns the model into HTML +3. **Update** - A function that updates the model based on messages + +``` + ┌──────────────────────────────────────────┐ + │ │ + │ User clicks button │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │ Message │ │ + │ └──────────────┘ │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ Update │───▶│ Model │ │ + │ └──────────────┘ └──────────────┘ │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │ View │ │ + │ └──────────────┘ │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │ HTML │───┘ + │ └──────────────┘ + │ + └──────────────────────────────────────────┘ +``` + +### Redux Connection + +If you've used Redux, you already know TEA! + +| Elm | Redux | +|-----|-------| +| Model | State/Store | +| Msg | Action | +| Update | Reducer | +| View | React Component | + +## Your First Interactive App: Counter + +Let's build a counter that you can increment and decrement. + +Create a new file `src/Counter.elm`: + +```elm +module Counter exposing (main) + +import Browser +import Html exposing (Html, button, div, text) +import Html.Events exposing (onClick) + + +-- MODEL + +type alias Model = + { count : Int + } + + +init : Model +init = + { count = 0 + } + + +-- UPDATE + +type Msg + = Increment + | Decrement + + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + Decrement -> + { model | count = model.count - 1 } + + +-- VIEW + +view : Model -> Html Msg +view model = + div [] + [ button [ onClick Decrement ] [ text "-" ] + , div [] [ text (String.fromInt model.count) ] + , button [ onClick Increment ] [ text "+" ] + ] + + +-- MAIN + +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +Run it with: +```bash +elm reactor +``` +Then open http://localhost:8000/src/Counter.elm + +### Breaking It Down + +#### 1. The Model + +```elm +type alias Model = + { count : Int + } + +init : Model +init = + { count = 0 + } +``` + +The model is just a record holding your state. `init` is the initial state. + +**JavaScript/Redux equivalent:** +```javascript +const initialState = { count: 0 }; +``` + +#### 2. Messages (Msg) + +```elm +type Msg + = Increment + | Decrement +``` + +Messages describe things that can happen. They're like Redux action types, but type-safe. + +**JavaScript/Redux equivalent:** +```javascript +const INCREMENT = 'INCREMENT'; +const DECREMENT = 'DECREMENT'; +``` + +#### 3. The Update Function + +```elm +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + Decrement -> + { model | count = model.count - 1 } +``` + +Takes a message and the current model, returns a new model. This is a **pure function** - no side effects! + +**JavaScript/Redux equivalent:** +```javascript +function reducer(state, action) { + switch (action.type) { + case INCREMENT: + return { ...state, count: state.count + 1 }; + case DECREMENT: + return { ...state, count: state.count - 1 }; + default: + return state; + } +} +``` + +#### 4. The View Function + +```elm +view : Model -> Html Msg +view model = + div [] + [ button [ onClick Decrement ] [ text "-" ] + , div [] [ text (String.fromInt model.count) ] + , button [ onClick Increment ] [ text "+" ] + ] +``` + +A pure function that takes the model and returns HTML. The `Html Msg` type means "HTML that can produce Msg values." + +**React equivalent:** +```jsx +function Counter({ count, dispatch }) { + return ( +
+ +
{count}
+ +
+ ); +} +``` + +#### 5. Wiring It Together + +```elm +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +`Browser.sandbox` connects everything. Elm handles: +- Calling `view` whenever the model changes +- Routing user events to `update` +- Re-rendering efficiently (virtual DOM) + +## HTML in Elm + +Every HTML element follows this pattern: +```elm +element attributes children +``` + +```elm +--
Hello
+div [ class "container" ] [ text "Hello" ] + +-- +button [ id "submit", disabled True ] [ text "Submit" ] + +-- +input [ type_ "text", value "hello" ] [] +``` + +Note: Some attributes like `type` use underscores (`type_`) because they conflict with Elm keywords. + +### Common Attributes + +```elm +import Html.Attributes exposing (..) + +-- Styling +class "my-class" +style "color" "red" +id "my-id" + +-- Form inputs +value "text" +placeholder "Enter text..." +disabled True +checked True + +-- Links and images +href "https://elm-lang.org" +src "image.png" +alt "Description" +``` + +### Common Events + +```elm +import Html.Events exposing (..) + +onClick Msg -- Button clicks +onInput (String -> Msg) -- Text input +onSubmit Msg -- Form submission +onMouseEnter Msg -- Mouse enter +onMouseLeave Msg -- Mouse leave +``` + +## Example 2: Text Input + +```elm +module TextInput exposing (main) + +import Browser +import Html exposing (Html, div, input, text) +import Html.Attributes exposing (placeholder, value) +import Html.Events exposing (onInput) + + +-- MODEL + +type alias Model = + { content : String + } + + +init : Model +init = + { content = "" + } + + +-- UPDATE + +type Msg + = Change String + + +update : Msg -> Model -> Model +update msg model = + case msg of + Change newContent -> + { model | content = newContent } + + +-- VIEW + +view : Model -> Html Msg +view model = + div [] + [ input + [ placeholder "Type something..." + , value model.content + , onInput Change + ] + [] + , div [] [ text ("You typed: " ++ model.content) ] + , div [] [ text ("Length: " ++ String.fromInt (String.length model.content)) ] + ] + + +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +### Key Point: Messages with Data + +```elm +type Msg + = Change String -- This message carries a String! + +-- onInput sends the input's value with the message +onInput Change -- When input changes, send: Change "new value" + +-- In update, extract the data +Change newContent -> + { model | content = newContent } +``` + +## Example 3: Todo List + +A more complex example with a list of items: + +```elm +module TodoList exposing (main) + +import Browser +import Html exposing (Html, button, div, input, li, text, ul) +import Html.Attributes exposing (placeholder, value) +import Html.Events exposing (onClick, onInput) + + +-- MODEL + +type alias Model = + { todos : List String + , newTodo : String + } + + +init : Model +init = + { todos = [] + , newTodo = "" + } + + +-- UPDATE + +type Msg + = UpdateNewTodo String + | AddTodo + | RemoveTodo Int + + +update : Msg -> Model -> Model +update msg model = + case msg of + UpdateNewTodo text -> + { model | newTodo = text } + + AddTodo -> + if String.isEmpty model.newTodo then + model + else + { model + | todos = model.todos ++ [ model.newTodo ] + , newTodo = "" + } + + RemoveTodo index -> + { model + | todos = removeAt index model.todos + } + + +removeAt : Int -> List a -> List a +removeAt index list = + List.take index list ++ List.drop (index + 1) list + + +-- VIEW + +view : Model -> Html Msg +view model = + div [] + [ div [] + [ input + [ placeholder "Add a todo..." + , value model.newTodo + , onInput UpdateNewTodo + ] + [] + , button [ onClick AddTodo ] [ text "Add" ] + ] + , ul [] (List.indexedMap viewTodo model.todos) + ] + + +viewTodo : Int -> String -> Html Msg +viewTodo index todo = + li [] + [ text todo + , button [ onClick (RemoveTodo index) ] [ text "x" ] + ] + + +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +### Key Concepts + +1. **List.indexedMap** - Like map, but gives you the index too +2. **Messages with multiple types of data** - `RemoveTodo Int` carries which item to remove +3. **Helper functions** - `removeAt` and `viewTodo` keep code organized + +## Exercise 4.1: Add Features to Counter + +Enhance the counter with: +1. A "Reset" button that sets count to 0 +2. A "Double" button that doubles the count + +
+Solution + +```elm +type Msg + = Increment + | Decrement + | Reset + | Double + + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + Decrement -> + { model | count = model.count - 1 } + + Reset -> + { model | count = 0 } + + Double -> + { model | count = model.count * 2 } + + +view : Model -> Html Msg +view model = + div [] + [ button [ onClick Decrement ] [ text "-" ] + , div [] [ text (String.fromInt model.count) ] + , button [ onClick Increment ] [ text "+" ] + , button [ onClick Reset ] [ text "Reset" ] + , button [ onClick Double ] [ text "Double" ] + ] +``` + +
+ +## Exercise 4.2: Temperature Converter + +Build a Celsius to Fahrenheit converter: +- Input field for Celsius +- Display the Fahrenheit equivalent +- Formula: F = C × 9/5 + 32 + +
+Solution + +```elm +module TempConverter exposing (main) + +import Browser +import Html exposing (Html, div, input, text) +import Html.Attributes exposing (placeholder, value) +import Html.Events exposing (onInput) + + +type alias Model = + { celsius : String + } + + +init : Model +init = + { celsius = "" + } + + +type Msg + = UpdateCelsius String + + +update : Msg -> Model -> Model +update msg model = + case msg of + UpdateCelsius value -> + { model | celsius = value } + + +celsiusToFahrenheit : Float -> Float +celsiusToFahrenheit c = + c * 9 / 5 + 32 + + +view : Model -> Html Msg +view model = + let + fahrenheit = + case String.toFloat model.celsius of + Just c -> + String.fromFloat (celsiusToFahrenheit c) ++ "°F" + + Nothing -> + "Enter a valid number" + in + div [] + [ input + [ placeholder "Celsius" + , value model.celsius + , onInput UpdateCelsius + ] + [] + , text " °C = " + , text fahrenheit + ] + + +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +
+ +## Exercise 4.3: Signup Form + +Build a signup form with: +- Name field +- Email field +- Password field +- "Sign Up" button +- Display "Welcome, {name}!" after clicking Sign Up + +
+Solution + +```elm +module Signup exposing (main) + +import Browser +import Html exposing (Html, button, div, input, text) +import Html.Attributes exposing (placeholder, type_, value) +import Html.Events exposing (onClick, onInput) + + +type alias Model = + { name : String + , email : String + , password : String + , submitted : Bool + } + + +init : Model +init = + { name = "" + , email = "" + , password = "" + , submitted = False + } + + +type Msg + = UpdateName String + | UpdateEmail String + | UpdatePassword String + | Submit + + +update : Msg -> Model -> Model +update msg model = + case msg of + UpdateName value -> + { model | name = value } + + UpdateEmail value -> + { model | email = value } + + UpdatePassword value -> + { model | password = value } + + Submit -> + { model | submitted = True } + + +view : Model -> Html Msg +view model = + if model.submitted then + div [] [ text ("Welcome, " ++ model.name ++ "!") ] + else + div [] + [ div [] + [ input + [ placeholder "Name" + , value model.name + , onInput UpdateName + ] + [] + ] + , div [] + [ input + [ placeholder "Email" + , type_ "email" + , value model.email + , onInput UpdateEmail + ] + [] + ] + , div [] + [ input + [ placeholder "Password" + , type_ "password" + , value model.password + , onInput UpdatePassword + ] + [] + ] + , button [ onClick Submit ] [ text "Sign Up" ] + ] + + +main = + Browser.sandbox + { init = init + , update = update + , view = view + } +``` + +
+ +## Key Takeaways + +1. **TEA has three parts:** Model, View, Update +2. **Model** holds all your application state +3. **View** is a pure function: Model → HTML +4. **Update** is a pure function: Msg → Model → Model +5. **Messages** describe events and can carry data +6. **Browser.sandbox** wires everything together +7. **HTML is just functions** with attributes and children + +## The Elm Architecture Benefits + +1. **No unexpected state changes** - All updates go through update +2. **Time-travel debugging** - Elm debugger lets you replay messages +3. **Easy testing** - Pure functions with no side effects +4. **Predictable** - Given a model and message, you know the result + +## What's Next? + +In [Lesson 5](05-lists-maybe.md), we'll dive deeper into: +- Working with Lists +- The Maybe type for handling missing values +- Pattern matching techniques +- Common List operations + +--- + +[← Previous: Lesson 3](03-functions.md) | [Next: Lesson 5 - Lists & Maybe →](05-lists-maybe.md) diff --git a/lessons/05-lists-maybe.md b/lessons/05-lists-maybe.md new file mode 100644 index 0000000..6b99780 --- /dev/null +++ b/lessons/05-lists-maybe.md @@ -0,0 +1,533 @@ +# Lesson 5: Lists & Maybe + +## Learning Goals + +By the end of this lesson, you will: +- Master List operations in Elm +- Understand the Maybe type deeply +- Handle missing values safely +- Use pattern matching effectively +- Work with Result for error handling + +## Lists in Elm + +Lists in Elm are **linked lists** (not arrays like JavaScript). + +```elm +-- Creating lists +numbers : List Int +numbers = [1, 2, 3, 4, 5] + +strings : List String +strings = ["apple", "banana", "cherry"] + +empty : List a +empty = [] + +-- Using cons operator (::) to prepend +moreNumbers = 0 :: numbers -- [0, 1, 2, 3, 4, 5] +``` + +### JavaScript Comparison + +| JavaScript | Elm | +|-----------|-----| +| `[1, 2, 3]` | `[1, 2, 3]` | +| `arr.push(4)` | Creates new list: `arr ++ [4]` | +| `arr.unshift(0)` | `0 :: arr` (prepend) | +| `arr[0]` | `List.head arr` (returns Maybe!) | +| `arr.length` | `List.length arr` | + +### Important: Lists Are Homogeneous + +```elm +-- This works +numbers = [1, 2, 3] + +-- This does NOT compile +mixed = [1, "hello", True] -- ERROR: all elements must be same type +``` + +JavaScript allows mixed arrays, Elm doesn't. This catches bugs early! + +## Essential List Functions + +### Creating Lists + +```elm +List.range 1 5 -- [1, 2, 3, 4, 5] +List.repeat 3 "hi" -- ["hi", "hi", "hi"] +List.singleton 42 -- [42] +``` + +### Transforming Lists + +```elm +List.map (\x -> x * 2) [1, 2, 3] +-- [2, 4, 6] + +List.map String.toUpper ["a", "b", "c"] +-- ["A", "B", "C"] + +-- With index +List.indexedMap (\i x -> String.fromInt i ++ ": " ++ x) ["a", "b"] +-- ["0: a", "1: b"] +``` + +### Filtering + +```elm +List.filter (\x -> x > 2) [1, 2, 3, 4, 5] +-- [3, 4, 5] + +List.filter String.isEmpty ["", "hello", "", "world"] +-- ["", ""] + +-- Partition: split into two lists +List.partition (\x -> x > 2) [1, 2, 3, 4, 5] +-- ([3, 4, 5], [1, 2]) +``` + +### Combining Lists + +```elm +[1, 2] ++ [3, 4] -- [1, 2, 3, 4] +List.concat [[1,2], [3,4]] -- [1, 2, 3, 4] +List.intersperse 0 [1,2,3] -- [1, 0, 2, 0, 3] +``` + +### Reducing (Folding) + +```elm +-- Sum +List.foldl (+) 0 [1, 2, 3, 4] +-- 10 + +-- Product +List.foldl (*) 1 [1, 2, 3, 4] +-- 24 + +-- Build a string +List.foldl (\item acc -> acc ++ item) "" ["a", "b", "c"] +-- "abc" + +-- Convenience functions +List.sum [1, 2, 3, 4] -- 10 +List.product [1, 2, 3, 4] -- 24 +List.maximum [1, 5, 3] -- Just 5 (returns Maybe!) +List.minimum [1, 5, 3] -- Just 1 +``` + +### Accessing Elements + +```elm +List.head [1, 2, 3] -- Just 1 +List.tail [1, 2, 3] -- Just [2, 3] +List.head [] -- Nothing +List.take 2 [1, 2, 3, 4] -- [1, 2] +List.drop 2 [1, 2, 3, 4] -- [3, 4] +``` + +Notice `head` and `tail` return `Maybe`! This is Elm protecting you. + +## The Maybe Type + +`Maybe` is Elm's solution to null/undefined. It forces you to handle missing values. + +```elm +type Maybe a + = Just a + | Nothing +``` + +### Why Maybe? + +```javascript +// JavaScript - Runtime crash waiting to happen +function getFirst(arr) { + return arr[0]; // undefined if empty! +} +const first = getFirst([]); +console.log(first.toUpperCase()); // CRASH! +``` + +```elm +-- Elm - Must handle both cases +getFirst : List a -> Maybe a +getFirst list = + List.head list + +case getFirst [] of + Just value -> + String.toUpper value -- Safe! + + Nothing -> + "No value" -- Must handle this case +``` + +### Working with Maybe + +#### Pattern Matching (Most Common) + +```elm +showResult : Maybe Int -> String +showResult maybeNum = + case maybeNum of + Just n -> + "The number is " ++ String.fromInt n + + Nothing -> + "No number found" +``` + +#### Maybe.withDefault + +```elm +-- Provide a default value +Maybe.withDefault 0 (Just 5) -- 5 +Maybe.withDefault 0 Nothing -- 0 + +-- Useful pattern +name = Maybe.withDefault "Anonymous" maybeName +``` + +#### Maybe.map + +Transform the value inside if it exists: + +```elm +Maybe.map (\x -> x * 2) (Just 5) -- Just 10 +Maybe.map (\x -> x * 2) Nothing -- Nothing + +-- Chain operations +Just "hello" + |> Maybe.map String.toUpper + |> Maybe.map String.reverse +-- Just "OLLEH" +``` + +#### Maybe.andThen + +Chain operations that might fail: + +```elm +-- String.toInt returns Maybe Int +String.toInt "42" -- Just 42 +String.toInt "hello" -- Nothing + +-- Chain fallible operations +parseAndDouble : String -> Maybe Int +parseAndDouble str = + String.toInt str + |> Maybe.andThen (\n -> Just (n * 2)) + +parseAndDouble "5" -- Just 10 +parseAndDouble "hello" -- Nothing +``` + +### Maybe in Practice + +```elm +type alias User = + { name : String + , email : Maybe String -- Email is optional + , age : Maybe Int -- Age is optional + } + +displayEmail : User -> String +displayEmail user = + case user.email of + Just email -> + email + + Nothing -> + "No email provided" + +-- Or more concisely: +displayEmail2 : User -> String +displayEmail2 user = + Maybe.withDefault "No email provided" user.email +``` + +## Pattern Matching + +Pattern matching is a powerful way to destructure data. + +### On Lists + +```elm +describeList : List a -> String +describeList list = + case list of + [] -> + "Empty list" + + [x] -> + "Single element" + + [x, y] -> + "Two elements" + + x :: rest -> + "Starts with something, has more" +``` + +### On Maybe + +```elm +case maybeName of + Just name -> + "Hello, " ++ name + + Nothing -> + "Hello, stranger" +``` + +### On Custom Types + +```elm +type Status + = Loading + | Success String + | Error String Int + +showStatus : Status -> String +showStatus status = + case status of + Loading -> + "Loading..." + + Success message -> + "Success: " ++ message + + Error message code -> + "Error " ++ String.fromInt code ++ ": " ++ message +``` + +### Wildcards + +Use `_` to ignore values: + +```elm +isLoading : Status -> Bool +isLoading status = + case status of + Loading -> + True + + _ -> + False -- Matches Success and Error +``` + +## The Result Type + +`Result` is like `Maybe` but carries error information: + +```elm +type Result error value + = Ok value + | Err error +``` + +### Example: Parsing Numbers + +```elm +parseAge : String -> Result String Int +parseAge input = + case String.toInt input of + Just age -> + if age < 0 then + Err "Age cannot be negative" + else if age > 150 then + Err "Age seems unrealistic" + else + Ok age + + Nothing -> + Err "Please enter a valid number" + +-- Using it +case parseAge userInput of + Ok age -> + "Your age is " ++ String.fromInt age + + Err errorMessage -> + "Invalid: " ++ errorMessage +``` + +### Result Functions + +```elm +Result.withDefault 0 (Ok 5) -- 5 +Result.withDefault 0 (Err "oops") -- 0 + +Result.map (\x -> x * 2) (Ok 5) -- Ok 10 +Result.map (\x -> x * 2) (Err "x") -- Err "x" +``` + +## Exercise 5.1: List Operations + +Given this list of numbers: +```elm +numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +``` + +Write expressions to: +1. Get all even numbers +2. Square each number +3. Get the sum of squares of even numbers + +
+Solution + +```elm +-- 1. Even numbers +evens = List.filter (\n -> modBy 2 n == 0) numbers +-- [2, 4, 6, 8, 10] + +-- 2. Square each +squares = List.map (\n -> n * n) numbers +-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] + +-- 3. Sum of squares of evens +sumOfSquaresOfEvens = + numbers + |> List.filter (\n -> modBy 2 n == 0) + |> List.map (\n -> n * n) + |> List.sum +-- 220 +``` + +
+ +## 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 + +```elm +safeDivide : Int -> Int -> Maybe Int +safeDivide numerator denominator = + if denominator == 0 then + Nothing + else + Just (numerator // denominator) + +safeDivide 10 2 -- Just 5 +safeDivide 10 0 -- Nothing +``` + +
+ +## Exercise 5.3: User Lookup + +Given this data structure: + +```elm +type alias User = + { id : Int + , name : String + } + +users : List User +users = + [ { id = 1, name = "Alice" } + , { id = 2, name = "Bob" } + , { id = 3, name = "Charlie" } + ] +``` + +Write `findUser : Int -> Maybe User` that finds a user by ID. + +
+Solution + +```elm +findUser : Int -> Maybe User +findUser id = + users + |> List.filter (\user -> user.id == id) + |> List.head + +-- Or using List.find (from List.Extra package) +-- findUser id = List.Extra.find (\user -> user.id == id) users + +findUser 2 -- Just { id = 2, name = "Bob" } +findUser 99 -- Nothing +``` + +
+ +## Exercise 5.4: Chain Maybe Operations + +Write a function that: +1. Takes a list of strings +2. Gets the first element +3. Converts it to an integer +4. Doubles it + +```elm +processFirst : List String -> Maybe Int +``` + +
+Solution + +```elm +processFirst : List String -> Maybe Int +processFirst strings = + strings + |> List.head + |> Maybe.andThen String.toInt + |> Maybe.map (\n -> n * 2) + +processFirst ["42", "hello"] -- Just 84 +processFirst ["hello", "42"] -- Nothing (can't parse) +processFirst [] -- Nothing (empty list) +``` + +
+ +## Key Takeaways + +1. **Lists are homogeneous** - All elements must be the same type +2. **Lists are immutable** - Operations return new lists +3. **Maybe replaces null** - Forces explicit handling of missing values +4. **Result carries error info** - Better than just Nothing +5. **Pattern matching** is powerful for destructuring data +6. **Use `|>` with Maybe.map and Maybe.andThen** for clean chains + +## Common Patterns + +```elm +-- Safely get and transform +List.head items + |> Maybe.map transform + |> Maybe.withDefault defaultValue + +-- Chain fallible operations +input + |> step1 + |> Maybe.andThen step2 + |> Maybe.andThen step3 + +-- Filter and transform in one go +items + |> List.filterMap toMaybe +``` + +## What's Next? + +In [Lesson 6](06-http-json.md), we'll learn about: +- HTTP requests +- JSON decoding +- Commands and subscriptions +- Side effects in Elm + +--- + +[← Previous: Lesson 4](04-tea.md) | [Next: Lesson 6 - HTTP & JSON →](06-http-json.md) diff --git a/lessons/06-http-json.md b/lessons/06-http-json.md new file mode 100644 index 0000000..84db273 --- /dev/null +++ b/lessons/06-http-json.md @@ -0,0 +1,840 @@ +# Lesson 6: HTTP & JSON + +## Learning Goals + +By the end of this lesson, you will: +- Understand Commands (Cmd) in Elm +- Make HTTP requests +- Decode JSON responses +- Handle loading states and errors +- Understand how Elm manages side effects + +## Side Effects in Elm + +Remember: Elm functions are **pure** - they can't do side effects directly. So how do we: +- Make HTTP requests? +- Generate random numbers? +- Get the current time? +- Write to local storage? + +Answer: **Commands (Cmd)** + +### The Command Pattern + +Instead of performing side effects, you **describe** them: + +```elm +-- Instead of actually making a request: +-- response = http.get("https://api.example.com") -- NOT how Elm works + +-- You return a command describing what you want: +update msg model = + case msg of + FetchData -> + ( model, Http.get { url = "...", expect = ... } ) + -- Returns the model AND a command +``` + +Elm runtime executes the command and sends the result back as a message. + +## Browser.element: Elm with Commands + +We need to upgrade from `Browser.sandbox` to `Browser.element`: + +```elm +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } +``` + +### Key Differences + +| Browser.sandbox | Browser.element | +|-----------------|-----------------| +| `init : Model` | `init : flags -> (Model, Cmd Msg)` | +| `update : Msg -> Model -> Model` | `update : Msg -> Model -> (Model, Cmd Msg)` | +| No side effects | Can perform side effects via Cmd | + +## Your First HTTP Request + +Let's fetch a random quote from an API. + +### Step 1: Set Up the Project + +```bash +mkdir http-example +cd http-example +elm init +elm install elm/http +elm install elm/json +``` + +### Step 2: The Complete Example + +```elm +module Main exposing (main) + +import Browser +import Html exposing (Html, button, div, p, text) +import Html.Events exposing (onClick) +import Http + + +-- MODEL + +type Model + = Loading + | Success String + | Failure String + + +init : () -> ( Model, Cmd Msg ) +init _ = + ( Loading, fetchQuote ) + + +-- UPDATE + +type Msg + = GotQuote (Result Http.Error String) + | FetchNewQuote + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + GotQuote result -> + case result of + Ok quote -> + ( Success quote, Cmd.none ) + + Err error -> + ( Failure (errorToString error), Cmd.none ) + + FetchNewQuote -> + ( Loading, fetchQuote ) + + +errorToString : Http.Error -> String +errorToString 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 message -> + "Bad body: " ++ message + + +-- HTTP + +fetchQuote : Cmd Msg +fetchQuote = + Http.get + { url = "https://api.quotable.io/random" + , expect = Http.expectJson GotQuote quoteDecoder + } + + +quoteDecoder : Json.Decode.Decoder String +quoteDecoder = + Json.Decode.field "content" Json.Decode.string + + +-- VIEW + +view : Model -> Html Msg +view model = + div [] + [ case model of + Loading -> + text "Loading..." + + Success quote -> + div [] + [ p [] [ text quote ] + , button [ onClick FetchNewQuote ] [ text "New Quote" ] + ] + + Failure error -> + div [] + [ p [] [ text ("Error: " ++ error) ] + , button [ onClick FetchNewQuote ] [ text "Try Again" ] + ] + ] + + +-- SUBSCRIPTIONS + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none + + +-- MAIN + +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } +``` + +### Breaking It Down + +#### The Model as State Machine + +```elm +type Model + = Loading + | Success String + | Failure String +``` + +Using a custom type for the model means we can only be in ONE state at a time. No more: +```javascript +// JavaScript +{ isLoading: false, hasError: true, data: null } // Confusing! +``` + +#### Init Returns a Command + +```elm +init : () -> ( Model, Cmd Msg ) +init _ = + ( Loading, fetchQuote ) -- Start in Loading state, fetch immediately +``` + +The `()` is called "unit" - it means no flags are passed. We return a **tuple** of model and command. + +#### Update Returns a Command + +```elm +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + GotQuote result -> + case result of + Ok quote -> + ( Success quote, Cmd.none ) -- No more commands + + Err error -> + ( Failure (errorToString error), Cmd.none ) +``` + +`Cmd.none` means "no command to execute." + +#### The HTTP Request + +```elm +fetchQuote : Cmd Msg +fetchQuote = + Http.get + { url = "https://api.quotable.io/random" + , expect = Http.expectJson GotQuote quoteDecoder + } +``` + +- `Http.get` creates a GET request command +- `expect` tells Elm what to do with the response +- `GotQuote` is the message constructor that will wrap the result +- `quoteDecoder` parses the JSON + +## JSON Decoding + +Elm doesn't just convert JSON to any type automatically. You must define a **decoder** that describes the JSON structure. + +### Why Decoders? + +JavaScript: +```javascript +const data = JSON.parse(response); +console.log(data.user.name); // Might crash if structure is wrong! +``` + +Elm: The decoder either succeeds with the exact type you expect, or fails gracefully. + +### Basic Decoders + +```elm +import Json.Decode exposing (Decoder, string, int, float, bool) + +-- Primitive decoders +string : Decoder String -- Decodes "hello" to "hello" +int : Decoder Int -- Decodes 42 to 42 +float : Decoder Float -- Decodes 3.14 to 3.14 +bool : Decoder Bool -- Decodes true to True +``` + +### Decoding Objects + +```elm +import Json.Decode exposing (Decoder, field, string, int) + +-- JSON: { "name": "Alice" } +nameDecoder : Decoder String +nameDecoder = + field "name" string + +-- JSON: { "user": { "name": "Alice" } } +nestedNameDecoder : Decoder String +nestedNameDecoder = + field "user" (field "name" string) +``` + +### Decoding Multiple Fields + +```elm +import Json.Decode exposing (Decoder, map2, field, string, int) + +type alias User = + { name : String + , age : Int + } + +-- JSON: { "name": "Alice", "age": 30 } +userDecoder : Decoder User +userDecoder = + map2 User + (field "name" string) + (field "age" int) +``` + +`map2` takes: +1. A constructor function (User) +2. Decoder for first field +3. Decoder for second field + +For more fields, use `map3`, `map4`, etc., or the `elm/json-decode-pipeline` package. + +### Using Pipeline Style (Recommended) + +Install: +```bash +elm install NoRedInk/elm-json-decode-pipeline +``` + +```elm +import Json.Decode exposing (Decoder, string, int) +import Json.Decode.Pipeline exposing (required, optional) + +type alias User = + { name : String + , age : Int + , email : Maybe String + } + +userDecoder : Decoder User +userDecoder = + Json.Decode.succeed User + |> required "name" string + |> required "age" int + |> optional "email" (Json.Decode.map Just string) Nothing +``` + +### Decoding Lists + +```elm +import Json.Decode exposing (Decoder, list, string) + +-- JSON: ["apple", "banana", "cherry"] +fruitsDecoder : Decoder (List String) +fruitsDecoder = + list string + +-- JSON: [{ "name": "Alice" }, { "name": "Bob" }] +usersDecoder : Decoder (List User) +usersDecoder = + list userDecoder +``` + +### Decoding with Maybe + +```elm +-- JSON: { "name": "Alice", "nickname": null } +-- Or: { "name": "Alice" } (nickname missing) + +type alias Person = + { name : String + , nickname : Maybe String + } + +personDecoder : Decoder Person +personDecoder = + map2 Person + (field "name" string) + (Json.Decode.maybe (field "nickname" string)) +``` + +### Testing Decoders + +```elm +import Json.Decode exposing (decodeString) + +result = decodeString userDecoder """{"name": "Alice", "age": 30}""" +-- Ok { name = "Alice", age = 30 } + +badResult = decodeString userDecoder """{"name": "Alice"}""" +-- Err ... (missing field "age") +``` + +## POST Requests with JSON Body + +```elm +import Http +import Json.Encode as Encode + +createUser : String -> Int -> Cmd Msg +createUser name age = + Http.post + { url = "https://api.example.com/users" + , body = Http.jsonBody (encodeUser name age) + , expect = Http.expectJson GotNewUser userDecoder + } + + +encodeUser : String -> Int -> Encode.Value +encodeUser name age = + Encode.object + [ ( "name", Encode.string name ) + , ( "age", Encode.int age ) + ] +``` + +### JSON Encoding + +```elm +import Json.Encode as Encode + +-- Primitives +Encode.string "hello" -- "hello" +Encode.int 42 -- 42 +Encode.float 3.14 -- 3.14 +Encode.bool True -- true +Encode.null -- null + +-- Objects +Encode.object + [ ( "name", Encode.string "Alice" ) + , ( "age", Encode.int 30 ) + ] +-- { "name": "Alice", "age": 30 } + +-- Lists +Encode.list Encode.int [1, 2, 3] +-- [1, 2, 3] +``` + +## Complete Example: GitHub User Search + +```elm +module GitHubSearch exposing (main) + +import Browser +import Html exposing (Html, button, div, img, input, li, text, ul) +import Html.Attributes exposing (placeholder, src, value, width) +import Html.Events exposing (onClick, onInput) +import Http +import Json.Decode exposing (Decoder, field, int, list, string) + + +-- MODEL + +type alias User = + { login : String + , avatarUrl : String + , id : Int + } + + +type Model + = Initial + | Loading String + | Success String (List User) + | Failure String String + + +init : () -> ( Model, Cmd Msg ) +init _ = + ( Initial, Cmd.none ) + + +-- UPDATE + +type Msg + = UpdateSearch String + | Search + | GotUsers (Result Http.Error (List User)) + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + UpdateSearch query -> + case model of + Initial -> + ( Initial, Cmd.none ) + + Loading q -> + ( Loading query, Cmd.none ) + + Success _ users -> + ( Success query users, Cmd.none ) + + Failure _ error -> + ( Failure query error, Cmd.none ) + + Search -> + let + query = + getQuery model + in + if String.isEmpty query then + ( model, Cmd.none ) + else + ( Loading query, searchUsers query ) + + GotUsers result -> + let + query = + getQuery model + in + case result of + Ok users -> + ( Success query users, Cmd.none ) + + Err error -> + ( Failure query (errorToString error), Cmd.none ) + + +getQuery : Model -> String +getQuery model = + case model of + Initial -> + "" + + Loading q -> + q + + Success q _ -> + q + + Failure q _ -> + q + + +errorToString : Http.Error -> String +errorToString 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 message -> + "Bad body: " ++ message + + +-- HTTP + +searchUsers : String -> Cmd Msg +searchUsers query = + Http.get + { url = "https://api.github.com/search/users?q=" ++ query + , expect = Http.expectJson GotUsers usersDecoder + } + + +usersDecoder : Decoder (List User) +usersDecoder = + field "items" (list userDecoder) + + +userDecoder : Decoder User +userDecoder = + Json.Decode.map3 User + (field "login" string) + (field "avatar_url" string) + (field "id" int) + + +-- VIEW + +view : Model -> Html Msg +view model = + div [] + [ div [] + [ input + [ placeholder "Search GitHub users..." + , value (getQuery model) + , onInput UpdateSearch + ] + [] + , button [ onClick Search ] [ text "Search" ] + ] + , viewResults model + ] + + +viewResults : Model -> Html Msg +viewResults model = + case model of + Initial -> + div [] [ text "Enter a search term" ] + + Loading _ -> + div [] [ text "Searching..." ] + + Success _ users -> + if List.isEmpty users then + div [] [ text "No users found" ] + else + ul [] (List.map viewUser users) + + Failure _ error -> + div [] [ text ("Error: " ++ error) ] + + +viewUser : User -> Html Msg +viewUser user = + li [] + [ img [ src user.avatarUrl, width 50 ] [] + , text (" " ++ user.login) + ] + + +-- SUBSCRIPTIONS + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none + + +-- MAIN + +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } +``` + +## Exercise 6.1: Basic HTTP Request + +Create an app that: +1. Fetches a random joke from `https://official-joke-api.appspot.com/random_joke` +2. Displays the setup and punchline +3. Has a "New Joke" button + +The API returns: +```json +{ + "type": "general", + "setup": "Why did the chicken...", + "punchline": "To get to the other side!", + "id": 123 +} +``` + +
+Solution + +```elm +module JokeApp exposing (main) + +import Browser +import Html exposing (Html, button, div, p, text) +import Html.Events exposing (onClick) +import Http +import Json.Decode exposing (Decoder, field, string) + + +type alias Joke = + { setup : String + , punchline : String + } + + +type Model + = Loading + | Success Joke + | Failure String + + +init : () -> ( Model, Cmd Msg ) +init _ = + ( Loading, fetchJoke ) + + +type Msg + = GotJoke (Result Http.Error Joke) + | FetchNewJoke + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + GotJoke result -> + case result of + Ok joke -> + ( Success joke, Cmd.none ) + + Err _ -> + ( Failure "Failed to fetch joke", Cmd.none ) + + FetchNewJoke -> + ( Loading, fetchJoke ) + + +fetchJoke : Cmd Msg +fetchJoke = + Http.get + { url = "https://official-joke-api.appspot.com/random_joke" + , expect = Http.expectJson GotJoke jokeDecoder + } + + +jokeDecoder : Decoder Joke +jokeDecoder = + Json.Decode.map2 Joke + (field "setup" string) + (field "punchline" string) + + +view : Model -> Html Msg +view model = + div [] + [ case model of + Loading -> + text "Loading..." + + Success joke -> + div [] + [ p [] [ text joke.setup ] + , p [] [ text joke.punchline ] + , button [ onClick FetchNewJoke ] [ text "New Joke" ] + ] + + Failure error -> + div [] + [ text error + , button [ onClick FetchNewJoke ] [ text "Try Again" ] + ] + ] + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none + + +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } +``` + +
+ +## Exercise 6.2: Nested JSON Decoding + +Write a decoder for this JSON: + +```json +{ + "user": { + "profile": { + "name": "Alice", + "bio": "Elm enthusiast" + }, + "stats": { + "followers": 100, + "following": 50 + } + } +} +``` + +Into this type: + +```elm +type alias UserInfo = + { name : String + , bio : String + , followers : Int + , following : Int + } +``` + +
+Solution + +```elm +import Json.Decode exposing (Decoder, field, int, map4, string) + + +userInfoDecoder : Decoder UserInfo +userInfoDecoder = + map4 UserInfo + (field "user" (field "profile" (field "name" string))) + (field "user" (field "profile" (field "bio" string))) + (field "user" (field "stats" (field "followers" int))) + (field "user" (field "stats" (field "following" int))) + + +-- Or using Json.Decode.at for cleaner nested access: +userInfoDecoder2 : Decoder UserInfo +userInfoDecoder2 = + map4 UserInfo + (Json.Decode.at [ "user", "profile", "name" ] string) + (Json.Decode.at [ "user", "profile", "bio" ] string) + (Json.Decode.at [ "user", "stats", "followers" ] int) + (Json.Decode.at [ "user", "stats", "following" ] int) +``` + +
+ +## Key Takeaways + +1. **Commands describe side effects** - Elm runtime performs them +2. **HTTP requests return Results** - Success or failure +3. **JSON decoders are type-safe** - Compiler ensures correct parsing +4. **Model states** - Use custom types to represent Loading/Success/Failure +5. **Update returns (Model, Cmd)** - New state plus commands to run + +## What's Next? + +In [Lesson 7](07-final-project.md), we'll build a complete application: +- A todo list with persistence +- Multiple pages +- All the concepts combined! + +--- + +[← Previous: Lesson 5](05-lists-maybe.md) | [Next: Lesson 7 - Final Project →](07-final-project.md) diff --git a/lessons/07-final-project.md b/lessons/07-final-project.md new file mode 100644 index 0000000..e776ebe --- /dev/null +++ b/lessons/07-final-project.md @@ -0,0 +1,856 @@ +# Lesson 7: Final Project - Building a Complete App + +## Learning Goals + +In this final lesson, you will: +- Apply everything you've learned +- Build a complete, functional application +- Understand how to structure larger Elm projects +- Learn about ports for JavaScript interop + +## Project: Task Manager + +We'll build a task manager with: +- Add, complete, and delete tasks +- Filter tasks (all, active, completed) +- Persist tasks to localStorage (using ports) +- Clean, modular code structure + +## Setting Up + +```bash +mkdir task-manager +cd task-manager +elm init +``` + +## The Complete Application + +Create `src/Main.elm`: + +```elm +port module Main exposing (main) + +import Browser +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode + + + +-- PORTS + + +port saveTasks : Encode.Value -> Cmd msg + + +port loadTasks : (Decode.Value -> msg) -> Sub msg + + + +-- MODEL + + +type alias Task = + { id : Int + , description : String + , completed : Bool + } + + +type Filter + = All + | Active + | Completed + + +type alias Model = + { tasks : List Task + , newTask : String + , nextId : Int + , filter : Filter + } + + +init : Decode.Value -> ( Model, Cmd Msg ) +init flags = + let + loadedTasks = + case Decode.decodeValue tasksDecoder flags of + Ok tasks -> + tasks + + Err _ -> + [] + + nextId = + case List.maximum (List.map .id loadedTasks) of + Just maxId -> + maxId + 1 + + Nothing -> + 1 + in + ( { tasks = loadedTasks + , newTask = "" + , nextId = nextId + , filter = All + } + , Cmd.none + ) + + + +-- DECODERS & ENCODERS + + +taskDecoder : Decoder Task +taskDecoder = + Decode.map3 Task + (Decode.field "id" Decode.int) + (Decode.field "description" Decode.string) + (Decode.field "completed" Decode.bool) + + +tasksDecoder : Decoder (List Task) +tasksDecoder = + Decode.list taskDecoder + + +encodeTask : Task -> Encode.Value +encodeTask task = + Encode.object + [ ( "id", Encode.int task.id ) + , ( "description", Encode.string task.description ) + , ( "completed", Encode.bool task.completed ) + ] + + +encodeTasks : List Task -> Encode.Value +encodeTasks tasks = + Encode.list encodeTask tasks + + + +-- UPDATE + + +type Msg + = UpdateNewTask String + | AddTask + | ToggleTask Int + | DeleteTask Int + | SetFilter Filter + | ClearCompleted + | LoadedTasks Decode.Value + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + UpdateNewTask text -> + ( { model | newTask = text }, Cmd.none ) + + AddTask -> + if String.isEmpty (String.trim model.newTask) then + ( model, Cmd.none ) + + else + let + newTask = + { id = model.nextId + , description = String.trim model.newTask + , completed = False + } + + newTasks = + model.tasks ++ [ newTask ] + in + ( { model + | tasks = newTasks + , newTask = "" + , nextId = model.nextId + 1 + } + , saveTasks (encodeTasks newTasks) + ) + + ToggleTask id -> + let + toggleTask task = + if task.id == id then + { task | completed = not task.completed } + + else + task + + newTasks = + List.map toggleTask model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + DeleteTask id -> + let + newTasks = + List.filter (\task -> task.id /= id) model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + SetFilter filter -> + ( { model | filter = filter }, Cmd.none ) + + ClearCompleted -> + let + newTasks = + List.filter (\task -> not task.completed) model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + LoadedTasks value -> + case Decode.decodeValue tasksDecoder value of + Ok tasks -> + ( { model | tasks = tasks }, Cmd.none ) + + Err _ -> + ( model, Cmd.none ) + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [ class "container" ] + [ h1 [] [ text "Task Manager" ] + , viewInput model.newTask + , viewFilters model.filter + , viewTasks model + , viewFooter model + ] + + +viewInput : String -> Html Msg +viewInput newTask = + div [ class "input-section" ] + [ input + [ placeholder "What needs to be done?" + , value newTask + , onInput UpdateNewTask + , onEnter AddTask + ] + [] + , button [ onClick AddTask ] [ text "Add" ] + ] + + +onEnter : Msg -> Attribute Msg +onEnter msg = + let + isEnter code = + if code == 13 then + Decode.succeed msg + + else + Decode.fail "not ENTER" + in + on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int)) + + +viewFilters : Filter -> Html Msg +viewFilters currentFilter = + div [ class "filters" ] + [ filterButton All currentFilter "All" + , filterButton Active currentFilter "Active" + , filterButton Completed currentFilter "Completed" + ] + + +filterButton : Filter -> Filter -> String -> Html Msg +filterButton filter currentFilter label = + button + [ classList [ ( "active", filter == currentFilter ) ] + , onClick (SetFilter filter) + ] + [ text label ] + + +viewTasks : Model -> Html Msg +viewTasks model = + let + filteredTasks = + filterTasks model.filter model.tasks + in + if List.isEmpty model.tasks then + p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ] + + else if List.isEmpty filteredTasks then + p [ class "empty-message" ] [ text "No tasks match this filter." ] + + else + ul [ class "task-list" ] + (List.map viewTask filteredTasks) + + +filterTasks : Filter -> List Task -> List Task +filterTasks filter tasks = + case filter of + All -> + tasks + + Active -> + List.filter (\t -> not t.completed) tasks + + Completed -> + List.filter .completed tasks + + +viewTask : Task -> Html Msg +viewTask task = + li [ classList [ ( "completed", task.completed ) ] ] + [ input + [ type_ "checkbox" + , checked task.completed + , onClick (ToggleTask task.id) + ] + [] + , span [ class "task-description" ] [ text task.description ] + , button + [ class "delete-btn" + , onClick (DeleteTask task.id) + ] + [ text "x" ] + ] + + +viewFooter : Model -> Html Msg +viewFooter model = + let + activeCount = + List.length (List.filter (\t -> not t.completed) model.tasks) + + completedCount = + List.length (List.filter .completed model.tasks) + + itemWord = + if activeCount == 1 then + "item" + + else + "items" + in + if List.isEmpty model.tasks then + text "" + + else + div [ class "footer" ] + [ span [] + [ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left") + ] + , if completedCount > 0 then + button [ onClick ClearCompleted ] + [ text "Clear completed" ] + + else + text "" + ] + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + loadTasks LoadedTasks + + + +-- MAIN + + +main : Program Decode.Value Model Msg +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } +``` + +## The HTML File with JavaScript + +Create `index.html`: + +```html + + + + + + Task Manager + + + +
+ + + + + +``` + +## Building and Running + +```bash +# Compile Elm to JavaScript +elm make src/Main.elm --output=elm.js + +# Open index.html in your browser +# On macOS: open index.html +# On Linux: xdg-open index.html +# On Windows: start index.html + +# Or use a local server +python -m http.server 8000 +# Then open http://localhost:8000 +``` + +## Understanding Ports + +Ports allow Elm to communicate with JavaScript for side effects Elm can't handle natively. + +### Defining Ports + +```elm +port module Main exposing (main) + +-- Outgoing: Elm -> JavaScript +port saveTasks : Encode.Value -> Cmd msg + +-- Incoming: JavaScript -> Elm +port loadTasks : (Decode.Value -> msg) -> Sub msg +``` + +### JavaScript Side + +```javascript +// Subscribe to Elm's outgoing port +app.ports.saveTasks.subscribe(function(tasks) { + localStorage.setItem('elm-tasks', JSON.stringify(tasks)); +}); + +// Send data to Elm's incoming port +app.ports.loadTasks.send(someData); +``` + +### Flags: Initial Data + +```elm +-- Elm receives flags in init +init : Decode.Value -> ( Model, Cmd Msg ) +init flags = + -- Decode flags (initial tasks from localStorage) +``` + +```javascript +// JavaScript passes flags at startup +Elm.Main.init({ + node: document.getElementById('app'), + flags: initialTasks +}); +``` + +## Code Organization Tips + +### For Larger Apps + +``` +src/ +├── Main.elm # Entry point +├── Model.elm # Model type and init +├── Update.elm # Update function and messages +├── View.elm # View functions +├── Types.elm # Shared types +├── Api.elm # HTTP requests +└── Ports.elm # Port declarations +``` + +### Splitting the Model + +```elm +-- Types.elm +module Types exposing (..) + +type alias Task = + { id : Int + , description : String + , completed : Bool + } + +type Filter + = All + | Active + | Completed +``` + +### Helper Functions + +Extract common patterns: + +```elm +-- Helpers.elm +module Helpers exposing (..) + +updateTask : Int -> (Task -> Task) -> List Task -> List Task +updateTask targetId transform tasks = + List.map + (\task -> + if task.id == targetId then + transform task + else + task + ) + tasks +``` + +## Testing Your Elm Code + +### Install elm-test + +```bash +npm install -g elm-test +elm-test init +``` + +### Writing Tests + +```elm +-- tests/TaskTests.elm +module TaskTests exposing (..) + +import Expect +import Test exposing (..) +import Main exposing (filterTasks, Filter(..)) + + +suite : Test +suite = + describe "Task filtering" + [ test "All filter shows all tasks" <| + \_ -> + let + tasks = + [ { id = 1, description = "Task 1", completed = False } + , { id = 2, description = "Task 2", completed = True } + ] + in + filterTasks All tasks + |> List.length + |> Expect.equal 2 + + , test "Active filter shows only incomplete" <| + \_ -> + let + tasks = + [ { id = 1, description = "Task 1", completed = False } + , { id = 2, description = "Task 2", completed = True } + ] + in + filterTasks Active tasks + |> List.length + |> Expect.equal 1 + ] +``` + +### Running Tests + +```bash +elm-test +``` + +## Exercises + +### Exercise 7.1: Add Edit Functionality + +Add the ability to edit task descriptions: +1. Double-click a task to edit it +2. Press Enter to save +3. Press Escape to cancel + +
+Hint + +Add a new field to the model: +```elm +type alias Model = + { tasks : List Task + , newTask : String + , nextId : Int + , filter : Filter + , editing : Maybe Int -- ID of task being edited + , editText : String + } +``` + +Add new messages: +```elm +type Msg + = ... + | StartEditing Int String + | UpdateEditText String + | SaveEdit + | CancelEdit +``` + +
+ +### Exercise 7.2: Add Due Dates + +Extend tasks to have optional due dates: +1. Add a date input when creating tasks +2. Display due dates with tasks +3. Highlight overdue tasks + +### Exercise 7.3: Add Categories + +Add task categories: +1. Tasks can be tagged with a category +2. Filter by category +3. Add color coding + +## Key Takeaways from This Tutorial + +1. **Elm is purely functional** - All functions are pure, side effects are managed +2. **Types are your friend** - They catch bugs at compile time +3. **The Elm Architecture** - Model, View, Update provides clean structure +4. **Maybe eliminates nulls** - No more "undefined is not a function" +5. **JSON decoders** ensure type-safe API integration +6. **Ports** bridge Elm and JavaScript for browser APIs + +## Where to Go From Here + +### Resources + +- [Official Elm Guide](https://guide.elm-lang.org/) - The canonical resource +- [Elm in Action](https://www.manning.com/books/elm-in-action) - Excellent book +- [Elm Packages](https://package.elm-lang.org/) - Browse available libraries +- [Elm Slack](https://elmlang.herokuapp.com/) - Friendly community + +### Practice Projects + +1. **Weather App** - Fetch and display weather data +2. **Markdown Preview** - Live markdown editor with preview +3. **Kanban Board** - Drag-and-drop task management +4. **Chat Application** - WebSocket integration with ports + +### Advanced Topics to Explore + +- **elm-ui** - Layout without CSS +- **elm-spa** - Single Page Applications +- **GraphQL with elm-graphql** +- **Web Components interop** + +## Congratulations! + +You've completed this Elm tutorial! You now have: +- A solid understanding of functional programming concepts +- Knowledge of Elm's type system +- Experience with The Elm Architecture +- Skills to build real-world applications + +The best way to learn more is to build something. Pick a project and start coding! + +--- + +[← Previous: Lesson 6](06-http-json.md) | [Back to README](../README.md) diff --git a/projects/task-manager/index.html b/projects/task-manager/index.html new file mode 100644 index 0000000..9c4d771 --- /dev/null +++ b/projects/task-manager/index.html @@ -0,0 +1,197 @@ + + + + + + Task Manager - Elm Tutorial Final Project + + + +
+ + + + + diff --git a/projects/task-manager/src/Main.elm b/projects/task-manager/src/Main.elm new file mode 100644 index 0000000..dd3b6ba --- /dev/null +++ b/projects/task-manager/src/Main.elm @@ -0,0 +1,370 @@ +port module Main exposing (main) + +{-| Task Manager - Final Project + +A complete task manager application demonstrating: +- The Elm Architecture +- Custom types for state management +- Ports for localStorage persistence +- List operations +- JSON encoding/decoding + +To run: +1. elm make src/Main.elm --output=elm.js +2. Open index.html in a browser + +-} + +import Browser +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode + + + +-- PORTS + + +port saveTasks : Encode.Value -> Cmd msg + + +port loadTasks : (Decode.Value -> msg) -> Sub msg + + + +-- MODEL + + +type alias Task = + { id : Int + , description : String + , completed : Bool + } + + +type Filter + = All + | Active + | Completed + + +type alias Model = + { tasks : List Task + , newTask : String + , nextId : Int + , filter : Filter + } + + +init : Decode.Value -> ( Model, Cmd Msg ) +init flags = + let + loadedTasks = + case Decode.decodeValue tasksDecoder flags of + Ok tasks -> + tasks + + Err _ -> + [] + + nextId = + case List.maximum (List.map .id loadedTasks) of + Just maxId -> + maxId + 1 + + Nothing -> + 1 + in + ( { tasks = loadedTasks + , newTask = "" + , nextId = nextId + , filter = All + } + , Cmd.none + ) + + + +-- DECODERS & ENCODERS + + +taskDecoder : Decoder Task +taskDecoder = + Decode.map3 Task + (Decode.field "id" Decode.int) + (Decode.field "description" Decode.string) + (Decode.field "completed" Decode.bool) + + +tasksDecoder : Decoder (List Task) +tasksDecoder = + Decode.list taskDecoder + + +encodeTask : Task -> Encode.Value +encodeTask task = + Encode.object + [ ( "id", Encode.int task.id ) + , ( "description", Encode.string task.description ) + , ( "completed", Encode.bool task.completed ) + ] + + +encodeTasks : List Task -> Encode.Value +encodeTasks tasks = + Encode.list encodeTask tasks + + + +-- UPDATE + + +type Msg + = UpdateNewTask String + | AddTask + | ToggleTask Int + | DeleteTask Int + | SetFilter Filter + | ClearCompleted + | LoadedTasks Decode.Value + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + UpdateNewTask text -> + ( { model | newTask = text }, Cmd.none ) + + AddTask -> + if String.isEmpty (String.trim model.newTask) then + ( model, Cmd.none ) + + else + let + newTask = + { id = model.nextId + , description = String.trim model.newTask + , completed = False + } + + newTasks = + model.tasks ++ [ newTask ] + in + ( { model + | tasks = newTasks + , newTask = "" + , nextId = model.nextId + 1 + } + , saveTasks (encodeTasks newTasks) + ) + + ToggleTask id -> + let + toggleTask task = + if task.id == id then + { task | completed = not task.completed } + + else + task + + newTasks = + List.map toggleTask model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + DeleteTask id -> + let + newTasks = + List.filter (\task -> task.id /= id) model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + SetFilter filter -> + ( { model | filter = filter }, Cmd.none ) + + ClearCompleted -> + let + newTasks = + List.filter (\task -> not task.completed) model.tasks + in + ( { model | tasks = newTasks } + , saveTasks (encodeTasks newTasks) + ) + + LoadedTasks value -> + case Decode.decodeValue tasksDecoder value of + Ok tasks -> + ( { model | tasks = tasks }, Cmd.none ) + + Err _ -> + ( model, Cmd.none ) + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [ class "container" ] + [ h1 [] [ text "Task Manager" ] + , viewInput model.newTask + , viewFilters model.filter + , viewTasks model + , viewFooter model + ] + + +viewInput : String -> Html Msg +viewInput newTask = + div [ class "input-section" ] + [ input + [ placeholder "What needs to be done?" + , value newTask + , onInput UpdateNewTask + , onEnter AddTask + ] + [] + , button [ onClick AddTask ] [ text "Add" ] + ] + + +onEnter : Msg -> Attribute Msg +onEnter msg = + let + isEnter code = + if code == 13 then + Decode.succeed msg + + else + Decode.fail "not ENTER" + in + on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int)) + + +viewFilters : Filter -> Html Msg +viewFilters currentFilter = + div [ class "filters" ] + [ filterButton All currentFilter "All" + , filterButton Active currentFilter "Active" + , filterButton Completed currentFilter "Completed" + ] + + +filterButton : Filter -> Filter -> String -> Html Msg +filterButton filter currentFilter label = + button + [ classList [ ( "active", filter == currentFilter ) ] + , onClick (SetFilter filter) + ] + [ text label ] + + +viewTasks : Model -> Html Msg +viewTasks model = + let + filteredTasks = + filterTasks model.filter model.tasks + in + if List.isEmpty model.tasks then + p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ] + + else if List.isEmpty filteredTasks then + p [ class "empty-message" ] [ text "No tasks match this filter." ] + + else + ul [ class "task-list" ] + (List.map viewTask filteredTasks) + + +filterTasks : Filter -> List Task -> List Task +filterTasks filter tasks = + case filter of + All -> + tasks + + Active -> + List.filter (\t -> not t.completed) tasks + + Completed -> + List.filter .completed tasks + + +viewTask : Task -> Html Msg +viewTask task = + li [ classList [ ( "completed", task.completed ) ] ] + [ input + [ type_ "checkbox" + , checked task.completed + , onClick (ToggleTask task.id) + ] + [] + , span [ class "task-description" ] [ text task.description ] + , button + [ class "delete-btn" + , onClick (DeleteTask task.id) + ] + [ text "x" ] + ] + + +viewFooter : Model -> Html Msg +viewFooter model = + let + activeCount = + List.length (List.filter (\t -> not t.completed) model.tasks) + + completedCount = + List.length (List.filter .completed model.tasks) + + itemWord = + if activeCount == 1 then + "item" + + else + "items" + in + if List.isEmpty model.tasks then + text "" + + else + div [ class "footer" ] + [ span [] + [ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left") + ] + , if completedCount > 0 then + button [ onClick ClearCompleted ] + [ text "Clear completed" ] + + else + text "" + ] + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + loadTasks LoadedTasks + + + +-- MAIN + + +main : Program Decode.Value Model Msg +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } diff --git a/solutions/02-counter/src/Main.elm b/solutions/02-counter/src/Main.elm new file mode 100644 index 0000000..60cf317 --- /dev/null +++ b/solutions/02-counter/src/Main.elm @@ -0,0 +1,85 @@ +module Main exposing (main) + +{-| Counter Solution + +This is the solution to Exercise 2 with: +- Reset button +- Double button +- Prevents count from going below 0 + +-} + +import Browser +import Html exposing (Html, button, div, text) +import Html.Events exposing (onClick) + + + +-- MODEL + + +type alias Model = + { count : Int + } + + +init : Model +init = + { count = 0 + } + + + +-- UPDATE + + +type Msg + = Increment + | Decrement + | Reset + | Double + + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + Decrement -> + -- Prevent going below 0 + { model | count = max 0 (model.count - 1) } + + Reset -> + { model | count = 0 } + + Double -> + { model | count = model.count * 2 } + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [] + [ button [ onClick Decrement ] [ text "-" ] + , div [] [ text (String.fromInt model.count) ] + , button [ onClick Increment ] [ text "+" ] + , button [ onClick Reset ] [ text "Reset" ] + , button [ onClick Double ] [ text "Double" ] + ] + + + +-- MAIN + + +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , update = update + , view = view + } diff --git a/solutions/03-temperature/src/Main.elm b/solutions/03-temperature/src/Main.elm new file mode 100644 index 0000000..2703577 --- /dev/null +++ b/solutions/03-temperature/src/Main.elm @@ -0,0 +1,124 @@ +module Main exposing (main) + +{-| Temperature Converter Solution + +Converts between Celsius and Fahrenheit + +-} + +import Browser +import Html exposing (Html, div, input, text) +import Html.Attributes exposing (placeholder, value) +import Html.Events exposing (onInput) + + + +-- MODEL + + +type alias Model = + { celsius : String + , fahrenheit : String + } + + +init : Model +init = + { celsius = "" + , fahrenheit = "" + } + + + +-- UPDATE + + +type Msg + = UpdateCelsius String + | UpdateFahrenheit String + + +update : Msg -> Model -> Model +update msg model = + case msg of + UpdateCelsius value -> + { model + | celsius = value + , fahrenheit = + case String.toFloat value of + Just c -> + String.fromFloat (celsiusToFahrenheit c) + + Nothing -> + "" + } + + UpdateFahrenheit value -> + { model + | fahrenheit = value + , celsius = + case String.toFloat value of + Just f -> + String.fromFloat (fahrenheitToCelsius f) + + Nothing -> + "" + } + + + +-- HELPER FUNCTIONS + + +celsiusToFahrenheit : Float -> Float +celsiusToFahrenheit c = + c * 9 / 5 + 32 + + +fahrenheitToCelsius : Float -> Float +fahrenheitToCelsius f = + (f - 32) * 5 / 9 + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [] + [ div [] + [ input + [ placeholder "Celsius" + , value model.celsius + , onInput UpdateCelsius + ] + [] + , text " °C" + ] + , div [] + [ text " = " + ] + , div [] + [ input + [ placeholder "Fahrenheit" + , value model.fahrenheit + , onInput UpdateFahrenheit + ] + [] + , text " °F" + ] + ] + + + +-- MAIN + + +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , update = update + , view = view + }