Elm, un langage
simple et fonctionnel
pour le front end
Loïc Knuchel
Community sponsors
Loïc Knuchel
Principal Engineer
@loicknuchel
Elm
PARIS 2022
Side-project = Pas de maintenance = Elm
Next-Gen ERD
Elm
Simplicité / Robustesse / Consistance
Plutôt que
Compatibilité / Familiarité
PARIS 2022
Simplicité
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
Robustesse
PARIS 2022
Consistance
PARIS 2022
Consistance
PARIS 2022
Consistance
PARIS 2022
Consistance
PARIS 2022
Consistance
PARIS 2022
Compatibilité
PARIS 2022
Familiarité
PARIS 2022
Hors Normes
PARIS 2022
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
Elm runtime
update: Msg -> Model -> Model
Msg
Model
Html Msg
init: Model
view: Model -> Html Msg
PARIS 2022
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
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
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
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
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
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
Créons une “vraie” app…
PARIS 2022
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
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
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
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
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
Elm book!
$ elm install dtwrks/elm-book
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" ) ]
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
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
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
// 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
My Pokedex
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
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
My Pokedex
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
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
PARIS 2022
PARIS 2022
Local Storage
PARIS 2022
Favorites
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
Favorites
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
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
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
PARIS 2022
Random
PARIS 2022
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)
SelectPokemon p ->
( { model | selected = Just p }, Cmd.none )
generate : (a -> msg) -> Generator a -> Cmd msg
uniform : a -> List a -> Generator a
PARIS 2022
PARIS 2022
Forms
PARIS 2022
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
PARIS 2022
PARIS 2022
1 an
et demi
30k LOC de Elm
Community sponsors
Merci!
Questions?
Community sponsors