1 of 65

Elm, un langage

simple et fonctionnel

pour le front end

Loïc Knuchel

Community sponsors

2 of 65

3 of 65

Loïc Knuchel

Principal Engineer

@loicknuchel

4 of 65

5 of 65

Elm

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

PARIS 2022

6 of 65

7 of 65

8 of 65

Side-project = Pas de maintenance = Elm

9 of 65

Next-Gen ERD

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

https://azimutt.app

github.com / azimuttapp / azimutt

10 of 65

Elm

Simplicité / Robustesse / Consistance

Plutôt que

Compatibilité / Familiarité

PARIS 2022

11 of 65

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

Valeur par défaut

Introspection

API riche

Inférence

Higher Kinded Types

String interpolation

PARIS 2022

12 of 65

Robustesse

  • Pas de fonction partielle
  • Pas de null / undefined
  • Pas d’exception 🤯

PARIS 2022

13 of 65

Consistance

PARIS 2022

14 of 65

Consistance

PARIS 2022

15 of 65

Consistance

PARIS 2022

16 of 65

Consistance

PARIS 2022

17 of 65

Consistance

PARIS 2022

18 of 65

Consistance

PARIS 2022

19 of 65

Compatibilité

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

PARIS 2022

20 of 65

Familiarité

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

PARIS 2022

21 of 65

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é

PARIS 2022

22 of 65

23 of 65

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}

PARIS 2022

24 of 65

Elm runtime

update: Msg -> Model -> Model

Msg

Model

Html Msg

init: Model

view: Model -> Html Msg

PARIS 2022

25 of 65

26 of 65

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 : Int -> Int -> Position -> Position

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

Type de retour

Objet principal

Paramètres

PARIS 2022

27 of 65

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

PARIS 2022

28 of 65

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

PARIS 2022

29 of 65

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)

PARIS 2022

30 of 65

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

PARIS 2022

31 of 65

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}

PARIS 2022

32 of 65

Créons une “vraie” app…

  • “Multi-page”
  • HTTP
  • LocalStorage
  • Random
  • Forms

PARIS 2022

33 of 65

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": {}

}

}

PARIS 2022

34 of 65

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

PARIS 2022

35 of 65

Add tailwind

$ npx tailwindcss init

// tailwind.config.js

module.exports = {

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

theme: {

extend: {},

},

plugins: [],

}

/* styles.css */

@tailwind base;

@tailwind components;

@tailwind utilities;

PARIS 2022

36 of 65

Hello World!

-- src/Main.elm

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

import Browser

import Html exposing (Html, text)

type alias Flags = ()

type alias Model = {}

type Msg = Noop

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 }

PARIS 2022

37 of 65

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>

PARIS 2022

38 of 65

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 ]

PARIS 2022

39 of 65

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>

PARIS 2022

40 of 65

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

PARIS 2022

41 of 65

42 of 65

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

PARIS 2022

43 of 65

My Pokedex

-- src/Services/PokeApi.elm

type alias Items =

{ next : Maybe String, results : List String }

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 String

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}

PARIS 2022

44 of 65

My Pokedex

-- src/Main.elm

type alias Model =

{ pokemons : List Pokemon

, selected : Maybe Pokemon

, more : Maybe 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"

)

PARIS 2022

45 of 65

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 )

PARIS 2022

46 of 65

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 ]

))

PARIS 2022

47 of 65

PARIS 2022

48 of 65

PARIS 2022

49 of 65

Local Storage

PARIS 2022

50 of 65

Favorites

-- src/Services/Storage.elm

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

port setItem : Json.Value -> Cmd msg

port getItem : String -> Cmd msg

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

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

PARIS 2022

51 of 65

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)

})

PARIS 2022

52 of 65

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 "url" , 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 )

PARIS 2022

53 of 65

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

))

PARIS 2022

54 of 65

PARIS 2022

55 of 65

Random

PARIS 2022

56 of 65

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

PARIS 2022

57 of 65

PARIS 2022

58 of 65

Forms

PARIS 2022

59 of 65

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 ]

)

)

PARIS 2022

60 of 65

PARIS 2022

61 of 65

PARIS 2022

62 of 65

63 of 65

1 an

et demi

30k LOC de Elm

64 of 65

Community sponsors

65 of 65

Merci!

Questions?

Community sponsors