GithubHelp home page GithubHelp logo

elm-style-guide's Introduction

Elm Style Guide

These are the guidelines we follow when writing Elm code at NoRedInk.

Note to NoRedInkers: These conventions have evolved over time, so there will always be some parts of the code base that don't follow everything. This is how we want to write new code, but there's no urgency around changing old code to conform. Feel free to clean up old code if you like, but don't feel obliged.

Table of Contents

How to Namespace Modules

Nri.

Nri.Button, Nri.Emoji, Nri.Leaderboard

A reusable part of the site's look and feel, which could go in the visual style guide. While some parts could be made open source, these are tied directly to NRI stuff.

When adding a new abstraction to Nri, announce it on slack and seek as much feedback as possible! this will be used in multiple places.

Further breakdown of the module is subject to How to Structure Modules for Reuse.

Examples

  • Common navigation header with configurable buttons

Non-examples

  • elm-css colors and fonts should go in here

Data.

Data.Assignment, Data.Course, Data.User

Data (and functions related to that data) shared across multiple pages.

Examples

  • Data types for a concept shared between multiple views (e.g Data.StudentTask)
  • A type that represents a "base" type record definition. A simple example might be a Student, which you will then extend in Page (see below)
  • Helpers for those data types

Page.

Page.Writing.Rate.Main, Page.Writing.Rate.Update, Page.Writing.Rate.Model.Decoder

A page on the site, which has its own URL. These are not reusable, and implemented using a combination of types from Data and modules from Nri. Comments for usage instructions aren't required, as code isn't intended to be reusable.

The module name should follow the URL. Naming after the URL is subject to How to Structure Modules for A Page. The purpose of this convention is so when you have a URL, you can easily figure out where to find the module.

If a module ends with Main, everything between Page and Main MUST correspond to a URL.

Examples

  • Page.Admin.RelevantTerms.Main corresponds to the URL /admin/relevant_terms.
  • Page.Learn.ChooseSourceMaterials.Main corresponds to the URL /learn/choose_source_materials.
  • Page.Preferences.Main corresponds to the URL /preferences.
  • Page.Teacher.Courses.Assignments.Main corresponds to the URL /teach/courses/:id/assignments. N.B. The /:id is dropped from the module name to avoid too much complexity.

Non-examples

  • Page.Mastery.Main corresponds to no URL. We would expect /mastery to exist, but it doesn't. Finding the module from the URL will be hard.
  • Page.Teach.WritingCycles.Rate.Main corresponds to no URL. We would expect /teach/writing_cycles/rate or /teach/writing_cycles/:id/rate to exist, but neither do. Finding the module from the URL will be hard.

Top-level modules

Accordion, Dropdown

Something reusable that we might open source, that aren't tied directly to any NRI stuff. Name it what we'd name it if we'd already open-sourced it.

Make as much of this opensource-ready as possible:

  • Must have simple documentation explaining how to use the module. No need to go overboard, but it needs to be there. Imagine you're publishing the package on elm-package! Use --warn to get errors for missing documentation.
  • Expose Model and the Msg constructors.
  • Use type alias Model a = { a | b : c } to allow extending of things.
  • Provide an API file as example usage of the module.
  • Follow either the elm-api-component pattern, or the elm-html-widgets pattern

Examples

  • Filter component
  • Long polling component
  • Tabs component

How to Structure Modules for Reuse

When the module is small enough, it's fine to let a single file hold all relevant code:

  • Nri/
    • Button.elm

When the module gets more complex, break out each of the Elm architecture triad into its own file while keeping the top-level Elm file as the public interface to import:

  • Nri/
    • Leaderboard.elm -- Expose Model, init, view, update, and other types/functions necessary for use
    • Leaderboard/ (only exists if necessary)
      • Introduce private submodules as necessary

We don't have a metric to determine exactly when to move from a single-file module to a multi-file module: trust your gut feelings and aesthetics.

Anti-pattern

Don't do: Nri/Leaderboard/Main.elm - the filename Main.elm is reserved for entrypoints under the Page namespace, so that we can run an automatic check during CI, which enforces the stricter naming convention for modules under Page.

How to Structure Modules for A Page

Our Elm apps generally take this form:

  • Main.elm
    • type alias Flags = { ...a record that directly corresponds to the JSON page data... }
    • decoder : Json.Decode.Decoder Flags
    • type alias Model = { ...a record with fields... }
    • init : Flags -> (Model, Cmd Msg)
    • type Msg = ... variants for each possible message ...
    • update : Msg -> Model -> (Model, Cmd Msg)
    • view : Model -> Html Msg

Inside Model, we contain the actual model for the view state of our program. Note that we generally don't include non-view state inside here, preferring to instead generalize things away from the view where possible. For example, we might have a record with a list of assignments in our Model file, but the assignment type itself would be in a module called Data.Assignment.

Msg, update contains our update code. Inside here most of our business logic lives.

Inside view, we define the view for our model and set up any event handlers we need.

Flags, decoder : Decoder Flags is a decoder for the flags of the app. We aim to keep our decoders basic and so decode into a special Flags type that mirrors the structure of the raw JSON instead of the structure of the Model type. The Flags and Model modules should not depend on each other.

Main.elm is our entry file. Here, we import everything from the other files and actually connect everything together.

It calls Html.programWithFlags with:

  • init, runs decoder and turns the resulting Flags type into a Model.
  • update
  • view
  • subscriptions, defined top-level if there are any subscriptions, or simply an inline \model -> Sub.none if the page has no subscriptions.

Additionally we setup ports for interop with JS in this file. We run elm-make on this file to generate a JS file that we can include elsewhere.

To summarize:

  • Main.elm

    • Our entry point. Decodes the flags, creates the initial model, calls Html.programWithFlags and sets up ports.
    • Compile target for elm-make
    • Imports the reusable module.
  • <ModuleName>.elm

    • Contains the Model type for the view alone.
    • Contains the Msg type for the view, and the update function.
    • Contains the view code
    • Contains the decoder : Decoder Flags and Flags type if necessary

Dependency Graph

Ports

All ports should bring things in as Json.Value

The single source of runtime errors that we have right now are through ports receiving values they shouldn't. If a port something : Signal Int receives a float, it will cause a runtime error. We can prevent this by just wrapping the incoming things as Json.Value, and handle the errorful data through a Decoder result instead.

Ports should always have documentation

I don't want to have to go out from our Elm files to find where a port is being used most of the time. Simply adding a line or two explaining what the port triggers, or where the values coming in from a port can help a lot.

Model

Model shouldn't have any view state within them if they aren't tied to views

For example, an assignment should not have a openPopout attribute. Doing so means we can't use that type again in another situation.

Naming

Use descriptive names instead of tacking on underscores

Instead of this:

-- Don't do this --
markDirty model =
  let
    model_ =
      { model | dirty = True }
  in
    model_

...just come up with a name.

-- Instead do this --
markDirty model =
  let
    dirtyModel =
      { model | dirty = True }
  in
    dirtyModel

Function Composition

Use anonymous function \_ -> over always

It's more concise, more recognizable as a function, and makes it easier to change your mind later and name the argument.

-- Don't do this --
on "click" Json.value (always (Signal.message address ()))
-- Instead do this --
on "click" Json.value (\_ -> Signal.message address ())

Only use backward function application <| when parens would be awkward

Instead of this:

-- Don't do this --
foo <| bar <| baz qux

...prefer using parentheses, because they'd look fine:

-- Instead do this --
foo (bar (baz qux))

However this would be awkward:

-- Don't do this --
customDecoder string
  (\str ->
    case str of
      "one" ->
        Result.Ok 1

      "two" ->
        Result.Ok 2

      "three" ->
        Result.Ok 3
    )

...so prefer this instead:

-- Instead do this --
customDecoder string
  <| \str ->
      case str of
        "one" ->
          Result.Ok 1

        "two" ->
          Result.Ok 2

        "three" ->
          Result.Ok 3

Always use Json.Decode.Pipeline instead of mapN

Even though this would work...

-- Don't do this --
algoliaResult : Decoder AlgoliaResult
algoliaResult =
  map6 AlgoliaResult
    (field "id" int)
    (field "name" string)
    (field "address" string)
    (field "city" string)
    (field "state" string)
    (field "zip" string)

...it's inconsistent with the longer decoders, and must be refactored if we want to add more fields.

Instead do this from the start:

-- Instead do this --
import Json.Decode.Pipeline exposing (required, decode)

algoliaResult : Decoder AlgoliaResult
algoliaResult =
  decode AlgoliaResult
    |> required "id" int
    |> required "name" string
    |> required "address" string
    |> required "city" string
    |> required "state" string
    |> required "zip" string

This will also make it easier to add optional fields where necessary.

json2elm can generate pipeline-style decoders from raw JSON.

Syntax

Use case..of over if where possible

case..of is clever as it will generate more efficent JS, and it also allows you to catch unmatched patterns at compile time. It's also cheap to extend this data with something more useful later on, like if you need to add another branch. This saves code diffs.

Identifiers

Prefer the use of union types over simple types for identifiers

Using a type alias for a unique identifier allows for easy mistakes supplying any old string or integer that happens to be in scope, rather than an actual identifier. Prefer using a single case union instead:

-- Don't do this --
type alias Student =
    { id : StudentId
    , name : String
    , age : Int
    }


type alias StudentId =
    String

getStudentDetails : StudentId -> Student
getStudentDetails sid =
    -- Passing a name in here by mistake will compile fine --
    ...
-- Do this instead --

-- In one module file --
module Thing.StudentId exposing (StudentId)

type StudentId
    = StudentId String
    
-- All conversions go in this module --

-- In the other module file --
module Thing.Student exposing (Student)

import Thing.StudentId exposing (StudentId)

type alias Student =
    { id : StudentId
    , name : String
    , age : Int
    }

getStudentDetails : StudentId -> Student
getStudentDetails sid =
    -- The compiler will reject strings --
    ...

The ID module should expose an intentionally minimal set of conversion functions:

  • We should never expose conversion functions before they're needed. Start with module CustomerId exposing (CustomerId) and expand the API from there only as necessary!
  • We should avoid exposing conversion functions whose types depend on the internals of the custom type. (For example, decoder : Decoder CustomerId is great, but functions like fromInt : Int -> CustomerId make the system brittle to implementation details. Exposing an Int -> CustomerId function should be avoided unless there is a very important production reason to introduce it. If tests need an Int -> CustomerId function, they can easily write one using CustomerId.decoder and Debug.crash, since in tests Debug.crash is as harmless as any other test failure.)

To use these types as keys in collections, use elm-sorter-experiment. You will need to create a sorter for your identifier - the ordering may or may not be relevant for your use case.

-- In the ID module --
module Thing.StudentId exposing (StudentId, studentIdSorter)

import Sort exposing (Sorter)
-- Rest of your code... --

studentIdSorter : Sorter StudentId
studentIdSorter =
    Sort.by (\(StudentId sid) -> sid) Sort.alphabetical

-- In use: --
module Thing.DoLogic

import Thing.StudentId exposing (StudentId, studentIdSorter)
import Sort.Dict


type alias Model =
    { students : Sort.Dict.Dict StudentId Student }

init : List Student -> Model
init students =
    students
        |> List.map (\s -> ( s.id, s ))
        |> Sort.Dict.fromList studentIdSorter

Notice that because Sort.Dict.Dict is an opaque type, your Model (and anything which depends upon it) does not depend on studentIdSorter. Apart from Sort.Dict.Dict, there is also a Sort.Dict.Set type.

Code safety in situations where there are multiple types of identifiers (think of a join in SQL terms) increases dramatically with the application of this technique.

Code Smells

If a module has a looong list of imports, consider refactoring

Having complicated imports hurts our compile time! I don't know what to say about this other than if you feel that there's something wrong with the top 40 lines of your module because of imports, then it might be time to move things out into another module. Trust your gut.

If a function can be pulled outside of a let binding, then do it

Giant let bindings hurt readability and performance. The less nested a function, the less functions are used in generated code.

The update function is especially prone to get longer and longer: keep it as small as possible. Smaller functions that don't live in a let binding are more reusable.

If your application has too many constructors for your Msg type, consider refactoring

Large case..of statements hurts compile time. It might be possible that some of your constructors can be combined, for example type Msg = Open | Close could actually be type Msg = SetOpenState Bool

Tooling

Use elm-init-scripts to start your projects

This will generate all the files mentioned above for you.

Use elm-ops-tooling to manage your projects

In particular, use elm_deps_sync to keep your main elm-package.json in sync with your test elm-package.json.

Use elm-format on all files

We run the latest version of elm-format to get uniform syntax formatting on our source code.

This has several benefits, not the least of which is that it renders many potential style discussions moot, making it easier to spend more time building things!

elm-style-guide's People

Contributors

ento avatar eeue56 avatar jwoudenberg avatar mavnn avatar avh4 avatar joneshf avatar brianhicks avatar arkham avatar mthadley avatar tad-lispy avatar

Watchers

James Cloos avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.