Elm, codez votre frontend
sans aucune erreur 🤯
Loïc Knuchel - @loicknuchel
Elm
SUNNY TECH 2023
@loicknuchel
Loïc Knuchel
Engineering manager
@loicknuchel
Side-project = Pas de maintenance = Elm
All-in-one
db exploration
Elm
Simplicité / Robustesse / Consistance
Plutôt que
Compatibilité / Familiarité
SUNNY TECH 2023
@loicknuchel
Simplicité
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
Robustesse
SUNNY TECH 2023
@loicknuchel
Consistance
SUNNY TECH 2023
@loicknuchel
Consistance
SUNNY TECH 2023
@loicknuchel
Consistance
SUNNY TECH 2023
@loicknuchel
Consistance
SUNNY TECH 2023
@loicknuchel
Consistance
SUNNY TECH 2023
@loicknuchel
Compatibilité
SUNNY TECH 2023
@loicknuchel
Familiarité
SUNNY TECH 2023
@loicknuchel
Hors Normes
SUNNY TECH 2023
@loicknuchel
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
Elm runtime
update: Msg -> Model -> Model
Msg
Model
Html Msg
init: Model
view: Model -> Html Msg
SUNNY TECH 2023
@loicknuchel
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
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
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
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
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
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
Créons une “vraie” app…
SUNNY TECH 2023
@loicknuchel
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
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
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
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
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
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 ]
SUNNY TECH 2023
@loicknuchel
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
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
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
// 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
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 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
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 )
SUNNY TECH 2023
@loicknuchel
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
SUNNY TECH 2023
@loicknuchel
SUNNY TECH 2023
@loicknuchel
Local Storage
SUNNY TECH 2023
@loicknuchel
Favorites
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
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)
})
SUNNY TECH 2023
@loicknuchel
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
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
SUNNY TECH 2023
@loicknuchel
2 ans
34k LOC de Elm
Merci!
Questions?
Random
SUNNY TECH 2023
@loicknuchel
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
SUNNY TECH 2023
@loicknuchel
SUNNY TECH 2023
@loicknuchel
Forms
SUNNY TECH 2023
@loicknuchel
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
SUNNY TECH 2023
@loicknuchel
SUNNY TECH 2023
@loicknuchel