NoRedInk style guide for our Elm code
Shell
Switch branches/tags
Nothing to show
Clone or download
joneshf Merge pull request #12 from NoRedInk/joneshf-patch-1
Describe main convention better
Latest commit e0cb820 Sep 18, 2017
Permalink
Failed to load latest commit information.
images Reverse order dependency graph Apr 4, 2017
README.md Describe main convention better Sep 15, 2017

README.md

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/
      • Model.elm
      • Update.elm
      • View.elm

Introduce Flags.elm (see below) and other 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
  • Model.elm
  • Update.elm
  • View.elm
  • Flags.elm

Inside Model.elm, 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.

Update.elm contains our update code. This includes the Msg types for our view. Inside here most of our business logic lives.

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

Flags.elm contains 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, defined in Main, runs Flags.decodeFlags and turns the resulting Flags type into a Model.
  • update is Update.update >> batchUpdate. See NoRedInk/rocket-update for details on batchUpdate.
  • view is simply View.view.
  • subscriptions, defined in Main, contains any subscriptions this app relies on.

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 Model, Update, View and Flags.
  • Model.elm

    • Contains the Model type for the view alone.
    • Imports nothing but generalized types that are used in the model
    • Exports Model
  • Update.elm

    • Contains the Msg type for the view, and the update function.
    • Imports Model
    • Exports update : Msg -> Model -> (Model, List (Cmd Msg)) and Msg
  • View.elm

    • Contains the view code
    • Imports Model and Update (for the Msg types)
    • Exports view : Model -> Html Msg
  • Flags.elm

    • Contains the flags decoder
    • Imports nothing but generalized decoders.
    • Exports Flags, decodeFlags : String -> Result String Flags

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 pipes |> over backticks

Instead of this:

-- Don't do this --
saveAccounts (List.map deactivateAccount accounts)
  `andThen` (\response -> sendToLogger response.successMessage)

...use |> and qualified names like normal, and use flip to obtain the desired argument order.

-- Instead do this --
accounts
  |> List.map deactivateAccount
  |> saveAccounts
  |> Task.andThen (\response -> sendToLogger response.successMessage)

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.

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!