Using modules in Elm

[This post is adapted from my book, Practical Elm for Busy Developers.]

As your Elm code base grows, you will want to start taking advantage of modules to split the code into manageable smaller parts.

In Elm, files and modules have a one-to-one correspondence. If you want to break out a piece of code into a separate file, you have to organise it as a module in that file.

The general approach I would suggest is to start your project with a single file (Main.elm), and keep adding to it until you see that it's becoming long and inconvenient to navigate, and you also identify some fairly independent piece of code that can be carved off into a module.

For the sake of this post, let's consider that we're working on an application that involves parsing some kind of input in different formats such as JSON and XML. At first all of the code is in one file:

src
  Main.elm

Perhaps we start writing JSON decoders in Main.elm as well, but then realise that JSON parsing is a fairly self-contained piece of functionality that can be extracted into a module.

Keeping in mind that we're going to parse more than one input format, it makes sense to create a directory called "Parsers" and put our new JSON module there. The resulting source tree looks like this:

src
  Parsers
    Json.elm
  Main.elm

In Json.elm, we have to name the module and also specify which parts of it are exposed to the outside world (we'll look at how that's done a bit further down):

module Parsers.Json exposing (..) 

In order to make the functions defined in Json.elm available in Main.elm, we need to import our newly created module the same way as we've been importing third party modules:

import Parsers.Json exposing (..) 

Note that the path to the file has to match up with the dot-separated name of the module, so the directory name has to be "Parsers" with a capital "P". If we change the directory name, we have to update the module name correspondingly, and vice versa.

Constraining exports

We've used the exposing keyword in two different contexts: in JSON.elm when naming the module, and in Main.elm when importing it.

In Plan.elm, the exposing keyword can be used to restrict what's visible outside the module.

For example, let's assume we have a few definitions for decoding the input data:

type Plan
    = PCte CteNode
    | PResult ResultNode
    | PSort SortNode


type alias PlanJson =
    { executionTime : Float
    , plan : Plan
    , planningTime : Float
    , triggers : List String
    }

decodePlanJson : Decode.Decoder PlanJson
decodePlanJson =
    decode PlanJson
        |> optional "Execution Time" Decode.float 0
        |> required "Plan" decodePlan
        |> optional "Planning Time" Decode.float 0
        |> optional "Triggers" (Decode.list Decode.string) []

Instead of exposing everything, we can whitelist the identifiers which are exposed by the module. For example, to expose just decodePlanJson and nothing else, we need to write:

module Parsers.Json exposing (decodePlanJson) 

We can also add type aliases and types into the mix:

module Parsers.Json exposing (decodePlanJson, PlanJson, Plan) 

This will make both PlanJson and Plan available for use in type annotations, and the PlanJson constructor will be made available as well.

There is a nuance for types like Plan as opposed to type aliases - while we've exported the Plan constructor, we haven't exposed any of the tags like PCte and PResult. So we will be able to use Plan as a type of record fields, and we will be able to use it in type signatures, but we will not be able to construct values of this type. This makes Plan a so called opaque type.

For now, I will just point out that if we want to be able to construct values of this type, then we need to change our whitelist:

module Parsers.Json exposing (decodePlanJson, PlanJson, Plan(..)) 

Note the (..) after Plan - it signifies that we want to expose all of the stuff included in that type definition.

Constraining imports

When importing the module, there are a few variations as well.

The simplest import statement looks like this:

import Parsers.Json 

This is called a qualified import, and it makes all the identifiers exposed by the module available in the current file, but they have to be prefixed with the module name: Parsers.Json.decodePlanJson, Parsers.Json.PCte.

This is a bit unwieldy, so we can also alias the name of the module with something easier to type:

import Parsers.Json as P 

Now we can write P.decodePlanJson and P.PCte.

We can dispense with the module name altogether by using an open import:

import Parsers.Json exposing (..) 

Importing a lot of identifiers can cause name clashes with other modules, however, so instead we can use an open import with a list of specific identifiers that we need:

import Parsers.Json exposing (decodePlanJson, PlanJson) 

This import statement means that we can use decodePlanJson and PlanJson but not PCte or anything else.

Summary

Elm provides the module functionality to allow us to organise the code and hide away the implementation details. Each module is written in its own file, and module naming corresponds to the file and directory structure. Elm gives you the ability to restrict what is exposed by the module. When you import a module, you have several options to deal with namespacing, and the ability to restrict what you import.

P.S. My book, Practical Elm, is going to be available as an early release in just a few days. If you want to level up your Elm skills, subscribe for the announcement below.

Comments or questions? I'm @alexkorban on Twitter.