1 of 62

Elm, codez votre frontend

sans aucune erreur 🤯

Loïc Knuchel - @loicknuchel

2 of 62

Elm

  • Compile vers JavaScript
  • Langage fonctionnel pur
  • Orienté simplicité
  • Hors normes?

SUNNY TECH 2023

@loicknuchel

3 of 62

Loïc Knuchel

Engineering manager

@loicknuchel

4 of 62

5 of 62

6 of 62

Side-project = Pas de maintenance = Elm

7 of 62

All-in-one

db exploration

  • Exploration
  • Documentation
  • Analyse
  • Design
  • Much more…

https://azimutt.app

github.com / azimuttapp / azimutt

8 of 62

Elm

Simplicité / Robustesse / Consistance

Plutôt que

Compatibilité / Familiarité

SUNNY TECH 2023

@loicknuchel

9 of 62

Simplicité

  • Documentation adressée aux débutants
  • Messages d’erreurs explicatifs

Langage minimaliste

Fonction

Record

Sun type

Pattern matching

Mutabilité

Classes / Héritage

Polymorphisme

Asynchrone

Threads

Introspection

API riche

Inférence

String interpolation

SUNNY TECH 2023

@loicknuchel

10 of 62

Robustesse

  • Pas d’effet de bords
  • Pas de null / undefined
  • Pas d’exception 🤯

SUNNY TECH 2023

@loicknuchel

11 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

12 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

13 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

14 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

15 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

16 of 62

Consistance

SUNNY TECH 2023

@loicknuchel

17 of 62

Compatibilité

  • Exécution isolée
  • Communication avec JavaScript via messages
  • Écosystème isolé

SUNNY TECH 2023

@loicknuchel

18 of 62

Familiarité

  • Style ML
  • Programmation fonctionnelle pure
  • Écosystème indépendant

SUNNY TECH 2023

@loicknuchel

19 of 62

Hors Normes

  • Dépendance indirectes dans le elm.json
  • Suite de test incomplète dans elm-test
  • Pas d’exceptions
  • Talks & articles focalisés sur le design
    • Make impossible state impossible
    • Design to provide guarantees
  • Progrès lent (pas de rush de feature)
  • Langage minimal
  • Écosystème isolé

SUNNY TECH 2023

@loicknuchel

20 of 62

21 of 62

Hello world!

type alias Model = Int

init : Model

init = 0

view : Model -> Html Msg

view model =

div [ style "margin" "50px" ]

[ button [ onClick Increment ] [ text "+" ]

, div [] [ text (String.fromInt model) ]

, button [ onClick Decrement ] [ text "-" ]

]

type Msg = Increment | Decrement

update : Msg -> Model -> Model

update msg model =

case msg of

Increment -> model + 1

Decrement -> model - 1

main = Browser.sandbox {init=init,view=view,update=update}

SUNNY TECH 2023

@loicknuchel

22 of 62

Elm runtime

update: Msg -> Model -> Model

Msg

Model

Html Msg

init: Model

view: Model -> Html Msg

SUNNY TECH 2023

@loicknuchel

23 of 62

24 of 62

Type alias

-- structural typing

type alias Position = { x : Int, y : Int }

p0 : Position

p0 = { x = 0, y = 0 }

p1 : Position

p1 = Position 10 10

p2 : Position

p2 = { p0 | x = 10 }

-- function

move dx dy position =

Position (position.x + dx) (position.y + dy)

p3 : Position

p3 = move 1 2 p0

p4 : Position

p4 = p1 |> move 1 2

-- function

move : Int -> Int -> Position -> Position

move dx dy position =

Position (position.x + dx) (position.y + dy)

Type de retour

Objet principal

Paramètres

p4 : Position

p4 = p1 |> move 1 2 |> move 3 4

SUNNY TECH 2023

@loicknuchel

25 of 62

Type alias

type alias Pos3d = { x : Int, y : Int, z : Int }

p5 : Pos3d

p5 = Pos3d 0 0 0

p6 : Position

p6 = move 1 2 p5

> Required: Position. Found: Pos3d. Extra fields: { z : Int }

move2d : Int -> Int -> {a | x:Int, y:Int} -> {a | x:Int, y:Int}

move2d dx dy pos =

{ pos | x = pos.x + dx, y = pos.y + dy }

p7 : Position

p7 = move2d 1 2 p5

SUNNY TECH 2023

@loicknuchel

26 of 62

Custom types

type Role = Guest | Member | Admin

type UserId = UserId String

type alias User = { id : UserId, name : String, role : Role }

u1 : User

u1 = User (UserId "1") "Loïc" Admin

-- generics

type Either a b = Left a | Right b

e1 : Either String b

e1 = Left "err"

e2 : Either a Int

e2 = Right 2

-- pattern matching

r1 : String

r1 =

case e1 of

Left e -> e

Right v -> v

SUNNY TECH 2023

@loicknuchel

27 of 62

Tuples

Lists

-- tuples

t1 : ( Int, String )

t1 = ( 1, "a" )

t2 : ( String, Int )

t2 = t1 |> (\( i, v ) -> ( v, i ))

-- lists

l1 : List Int

l1 = [ 1, 2, 3 ]

l2 : List Int

l2 = l1 |> List.map (\i -> i + 1)

SUNNY TECH 2023

@loicknuchel

28 of 62

HTML

h1 : Html msg

h1 = div [ class "main" ] [ text "Hello" ]

-- elm implementations

div : List (Attribute msg) -> List (Html msg) -> Html msg

div = Elm.Kernel.VirtualDom.node "div"

class : String -> Attribute msg

class = stringProperty "className"

text : String -> Html msg

text = VirtualDom.text

SUNNY TECH 2023

@loicknuchel

29 of 62

Hello world!

type alias Model = Int

init : Model

init = 0

view : Model -> Html Msg

view model =

div [ style "margin" "50px" ]

[ button [ onClick Increment ] [ text "+" ]

, div [] [ text (String.fromInt model) ]

, button [ onClick Decrement ] [ text "-" ]

]

type Msg = Increment | Decrement

update : Msg -> Model -> Model

update msg model =

case msg of

Increment -> model + 1

Decrement -> model - 1

main = Browser.sandbox {init=init,view=view,update=update}

SUNNY TECH 2023

@loicknuchel

30 of 62

Créons une “vraie” app…

  • “Multi-page”
  • HTTP
  • LocalStorage
  • Random (⏱️)
  • Forms (⏱️)

SUNNY TECH 2023

@loicknuchel

31 of 62

Elm init

$ mkdir pokedex && cd pokedex && elm init

{ // elm.json

"type": "application",

"source-directories": ["src"],

"elm-version": "0.19.1",

"dependencies": {

"direct": {

"elm/browser": "1.0.2",

"elm/core": "1.0.5",

"elm/html": "1.0.0"

},

"indirect": {

"elm/json": "1.1.3",

"elm/time": "1.0.0",

"elm/url": "1.0.0",

"elm/virtual-dom": "1.0.3"

}

},

"test-dependencies": {

"direct": {},

"indirect": {}

}

}

SUNNY TECH 2023

@loicknuchel

32 of 62

Setup project

$ npm install elm-live elm-format elm-review elm-book tailwindcss concurrently -D

{ // package.json

"devDependencies": {

"concurrently": "^7.5.0",

"elm-book": "^1.0.1",

"elm-format": "^0.8.5",

"elm-live": "^4.0.2",

"elm-review": "^2.7.6",

"tailwindcss": "^3.2.1"

}

}

$ npx elm-review init --template jfmengels/elm-review-config/application

SUNNY TECH 2023

@loicknuchel

33 of 62

Add tailwind

$ npx tailwindcss init

// tailwind.config.js

module.exports = {

content: ["./src/**/*.elm"],

theme: {

extend: {},

},

plugins: [],

}

/* styles.css */

@tailwind base;

@tailwind components;

@tailwind utilities;

SUNNY TECH 2023

@loicknuchel

34 of 62

Hello World!

-- src/Main.elm

module Main exposing (Flags, Model, Msg, main)

import Browser

import Html exposing (Html, text)

init : Flags -> ( Model, Cmd Msg )

init _ = ( {}, Cmd.none )

view : Model -> Document Msg

view _ = { title = "Elm Pokedex", body = [ text "Pokedex!" ] }

update : Msg -> Model -> ( Model, Cmd Msg )

update _ model = ( model, Cmd.none )

subscriptions : Model -> Sub Msg

subscriptions _ = Sub.none

main : Program Flags Model Msg

main = Browser.document { init = init, view = view, update = update, subscriptions = subscriptions }

type alias Flags = ()

type alias Model = {}

type Msg = Noop

SUNNY TECH 2023

@loicknuchel

35 of 62

Hello World!

<!-- index.html -->

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="UTF-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<title>Elm Pokedex</title>

<link rel="stylesheet" href="/dist/styles.css">

<script type="text/javascript" src="/dist/elm.js"></script>

</head>

<body>

<script>

Elm.Main.init()

</script>

</body>

</html>

SUNNY TECH 2023

@loicknuchel

36 of 62

Elm book!

$ elm install dtwrks/elm-book

-- src/Components/Badge.elm

module Components.Badge exposing (doc, red)

import ElmBook.Chapter as Chapter exposing (Chapter)

import Html exposing (Html, div, span, text)

import Html.Attributes exposing (class)

red : String -> Html msg

red v = div [ class "inline-flex ... text-red-800" ] [ text v ]

doc : Chapter x

doc = Chapter.chapter "Badge"

|> Chapter.renderComponentList [ ( "basic", red "red" ) ]

-- src/Components/Book.elm

module Components.Book exposing (DocState, main)

import Components.Badge as Badge

import ElmBook exposing (withChapters)

type alias DocState = {}

main : ElmBook.Book DocState

main = ElmBook.book "Pokedex Design System"

|> withChapters [ Badge.doc ]

SUNNY TECH 2023

@loicknuchel

37 of 62

Elm book!

<!-- book.html -->

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="UTF-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<title>ElmBook</title>

<link rel="stylesheet" href="/dist/styles.css">

<script type="text/javascript" src="/dist/book.js"></script>

</head>

<body>

<script>

Elm.Components.Book.init()

</script>

</body>

</html>

SUNNY TECH 2023

@loicknuchel

38 of 62

Build

{ // package.json

"scripts": {

"tw": "tailwindcss -i styles.css -o dist/styles.css -w",

"elm": "elm-live src/Main.elm -- --output dist/elm.js",

"book": "elm-live src/Components/Book.elm --port=8001 --start-page=book.html --pushstate -- --output dist/book.js",

"start": "concurrently \"npm run tw\" \"npm run elm\" \"npm run book\""

}

}

$ npm start

SUNNY TECH 2023

@loicknuchel

39 of 62

40 of 62

My Pokedex

-- src/Main.elm

type alias Model =

{ pokemons : List Pokemon

, selected : Maybe Pokemon

, more : Maybe Url

}

type alias Pokemon =

{id: Int, name: String, sprites: {front: Url, back: Url}}

type alias Url = String

type Msg

= GotPokemons (Result Http.Error Items)

| GotPokemon (Result Http.Error Pokemon)

| SelectPokemon Pokemon

| UnselectPokemon

init : Flags -> ( Model, Cmd Msg )

init _ =

( { pokemons = []

, selected = Nothing

, more = Nothing

}

, getPokemons GotPokemons "https://pokeapi.co/api/v2/pokemon"

)

SUNNY TECH 2023

@loicknuchel

41 of 62

// https://pokeapi.co/api/v2/pokemon?limit=3

{

"count": 1154,

"next": "https://pokeapi.co/api/v2/pokemon?offset=3&limit=3",

"previous": null,

"results": [

{ "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1" },

{ "name": "ivysaur", "url": "https://pokeapi.co/api/v2/pokemon/2" },

{ "name": "venusaur", "url": "https://pokeapi.co/api/v2/pokemon/3" }

]

}

get : (Result Http.Error (List String) -> msg) -> Cmd msg

get toMsg =

Http.get

{ url = "https://pokeapi.co/api/v2/pokemon"

, expect = Http.expectJson toMsg decode

}

decode : Decoder (List String)

decode =

Decode.field "results"

(Decode.list

(Decode.field "name" Decode.string)

)

$ elm install elm/http

$ elm install elm/json

get : { url : String, expect : Expect msg } -> Cmd msg

expectJson : (Result Error a -> msg) -> Decode.Decoder a -> Expect msg

SUNNY TECH 2023

@loicknuchel

42 of 62

My Pokedex

-- src/Services/PokeApi.elm

type alias Items =

{ next : Maybe Url, results : List Url }

getPokemons : (Result Error Items -> msg) -> String -> Cmd msg

getPokemons msg url =

Http.get {url=url , expect=Http.expectJson msg decodeItems}

decodeItems : Decoder Items

decodeItems =

Decode.map2 Items

(Decode.field "next" (Decode.maybe Decode.string))

(Decode.field "results" (Decode.list decodeItem))

decodeItem : Decoder Url

decodeItem = Decode.field "url" Decode.string

getPokemon : (Result Error Pokemon -> msg) -> String -> Cmd msg

getPokemon msg url =

Http.get {url=url, expect=Http.expectJson msg decodePokemon}

SUNNY TECH 2023

@loicknuchel

43 of 62

My Pokedex

-- src/Main.elm

update : Msg -> Model -> ( Model, Cmd Msg )

update msg m =

case msg of

GotPokemons r ->

( { m | more = r |> R.map .next |> R.withDefault Nothing }

, Cmd.batch (r

|> R.map (.results >> List.map(getPokemon GotPokemon))

|> R.withDefault []

)

)

GotPokemon r ->

( r |> R.map (\p -> { m | pokemons = p :: m.pokemons })

|> R.withDefault m

, Cmd.none

)

SelectPokemon p ->

( { model | selected = Just p }, Cmd.none )

UnselectPokemon ->

( { model | selected = Nothing }, Cmd.none )

SUNNY TECH 2023

@loicknuchel

44 of 62

My Pokedex

-- src/Main.elm

view : Model -> Document Msg

view =

{ title = "Elm Pokedex"

, body =

[ model.selected

|> Maybe.map viewPokemon

|> Maybe.withDefault (viewPokemons model.pokemons)

]

}

viewPokemon : Pokemon -> Html Msg

viewPokemon pokemon =

div [ onClick UnselectPokemon ] [ text pokemon.name ]

viewPokemons : List Pokemon -> Html Msg

viewPokemons pokemons =

div [] (pokemons |> List.map (\p ->

div [ onClick (SelectPokemon p) ] [ text p.name ]

))

type alias Model =

{ pokemons : List Pokemon

, selected : Maybe Pokemon

}

SUNNY TECH 2023

@loicknuchel

45 of 62

SUNNY TECH 2023

@loicknuchel

46 of 62

SUNNY TECH 2023

@loicknuchel

47 of 62

Local Storage

SUNNY TECH 2023

@loicknuchel

48 of 62

Favorites

-- src/Services/Storage.elm

port module Services.Storage exposing (getFavorites, gotFavorites, setFavorite)

setFavorite : Set Int -> Cmd msg

setFavorite ids =

setItem (Encode.object

[ ( "key", "favorites" |> Encode.string )

, ( "value", ids |> Encode.set Encode.int )

])

getFavorites : Cmd msg

getFavorites = getItem "favorites"

gotFavorites : (Result Decode.Error (Set Int) -> msg) -> Sub msg

gotFavorites toMsg =

(decodeValue (Decode.field "value" (Decode.list Decode.int))

>> Result.map Set.fromList

>> toMsg

) |> gotItem

port setItem : Json.Value -> Cmd msg

port getItem : String -> Cmd msg

port gotItem : (Json.Value -> msg) -> Sub msg

SUNNY TECH 2023

@loicknuchel

49 of 62

Favorites

// index.html

var app = Elm.Main.init()

app.ports.getItem.subscribe(function(key) {

var item = JSON.parse(localStorage.getItem(key))

app.ports.gotItem.send({key, value: item})

})

app.ports.setItem.subscribe(function(msg) {

localStorage.setItem(msg.key, JSON.stringify(msg.value))

app.ports.gotItem.send(msg)

})

SUNNY TECH 2023

@loicknuchel

50 of 62

Favorites

-- src/Main.elm

type alias Model = { ... , favorites : Set Int }

type Msg

= ...

| GotFavorites (Result Decode.Error (Set Int))

| ToggleFav Int

init : Flags -> ( Model, Cmd Msg )

init _ =

( { ... , favorites = Set.empty }

, Cmd.batch [ getPokemons GotPokemons "..." , getFavorites ] )

subscriptions : Model -> Sub Msg

subscriptions _ =

gotFavorites GotFavorites

update : Msg -> Model -> ( Model, Cmd Msg )

update msg m =

case msg of

GotFavorites r ->

( r |> R.map (\f -> {m | favorites=f}) |> R.withDefault m

, Cmd.none )

ToggleFav id ->

( m, m.favorites |> Set.toggle id |> setFavorite )

SUNNY TECH 2023

@loicknuchel

51 of 62

Favorites

-- src/Main.elm

viewPokemon : Set Int -> Pokemon -> Html Msg

viewPokemon favorites pkmn =

div []

[ text pkmn.name

, if favorites |> Set.member pkmn.id then

button [ onClick (ToggleFav pkmn.id) ] [ text "Starred" ]

else

button [ onClick (ToggleFav pkmn.id) ] [ text "Star" ]

, button [ onClick UnselectPokemon ] [ text "X" ]

]

viewPokemons : Set Int -> List Pokemon -> Html Msg

viewPokemons favorites pokemons =

div [] (pokemons |> List.map (\p ->

let

fav : String

fav =

if favorites |> Set.member p.id then

" *"

else

""

in

div [ onClick (SelectPokemon p) ]

[ text (p.name ++ fav) ]

))

SUNNY TECH 2023

@loicknuchel

52 of 62

SUNNY TECH 2023

@loicknuchel

53 of 62

2 ans

34k LOC de Elm

54 of 62

Merci!

Questions?

55 of 62

Random

SUNNY TECH 2023

@loicknuchel

56 of 62

Random

$ elm install elm/random

update : Msg -> Model -> ( Model, Cmd Msg )

update msg m =

case msg of

RandomPokemon ->

case m.pokemons of

[] ->

( m, Cmd.none )

h :: t ->

(m, Random.uniform h t |> Random.generate SelectPokemon)

-- src/Main.elm

type Msg

= ...

| RandomPokemon

SelectPokemon p ->

( { model | selected = Just p }, Cmd.none )

generate : (a -> msg) -> Generator a -> Cmd msg

uniform : a -> List a -> Generator a

SUNNY TECH 2023

@loicknuchel

57 of 62

SUNNY TECH 2023

@loicknuchel

58 of 62

Forms

SUNNY TECH 2023

@loicknuchel

59 of 62

Search

-- src/Main.elm

type alias Model = { ... , search : String }

type Msg

= ...

| ChangeSearch String

viewHeader : String -> Html Msg

viewHeader search =

div []

[ input [ value search, onInput ChangeSearch ] []

]

viewPokemons : String -> List Pokemon -> Html Msg

viewPokemons search pokemons =

div [] (pokemons

|> List.filter (\p -> p.name |> String.contains search)

|> List.map (\p ->

div [ onClick (SelectPokemon p) ]

[ text p.name ]

)

)

SUNNY TECH 2023

@loicknuchel

60 of 62

SUNNY TECH 2023

@loicknuchel

61 of 62

SUNNY TECH 2023

@loicknuchel

62 of 62