MEGA - Make Elm Great Again

This is the introduction article of MEGA: Make Elm Great Again series in which I will try to condense some helpful patterns and techniques that I learnt during real-world frontend applications in Elm. These articles are dedicated to mid or experienced Elm and functional programming developers so if you’ve never experienced such topics before you’ll probably find it obscure and unuseful.

Introduction to Elm

As I said before these articles are not thought for beginners but I find useful for everyone a general explanation of a language that you could fairly define exotic. The purpose is just to make a brief on the technical aspects which you probably have never thought about. So if you’re just curious or you’ve never used Elm before it may help you to make a more precise idea about it.

When we say Elm we talk about two different worlds that are connected but different:

  • TEA (The Elm Architecture) it’s the framework that implements the MVU pattern (Model View Update) that allows writing browser applications in the Elm language.
  • Elm, an ML family language. It’s a purely functional language strongly and statically typed. It supports type inference (but writing your type signatures is strongly recommended).

Elm is independent of the TEA but without the TEA you can’t do much (well you can always try your functions in elm-repl)

But what is all this in practice?

Elm is a compiled language

Elm is a true language with its own compiler Elm -> Javascript but there are some other projects for writing a compiler to other languages (WASM for example). The compiler produces javascript code that fully respects Ecma 5 standard. The final result is bundled in a whole monolithic js that contains all your working code, barely readable and highly optimized. This bundle can be directly included in some external bundle written by you.

How compiled Elm looks like How compiled Elm looks like.

The compiler can be launched directly via CLI or with a bundler (like webpack) plugin that targets .elm files. Use these plugins allows you to import and use your main Elm files directly in your javascript source like any other javascript module and make your Elm program run like any other javascript object.

Third-party Elm packages cannot include javascript (since the 0.19 version). TEA and its native libraries are not based on javascript features more recent than Ecma 5. All this means that Javascript compiled by Elm doesn’t need poly filling.

Functions are first-class travellers

Functions are the main skeleton and probably the only. Any nominal reference inside Elm code it’s a function, both when the function depends on some input (variable function) and when not (constant function). This allows us to express composition, application and partial application of functions natively, elegantly and expressively. Inside Elm a function like this

myFunc: A -> B -> C

can be evaluated differently by its use:

  • given A and B returns C
  • given A returns a fuction B -> C
  • given a function A -> B returns C

Brackets in type signatures are just a read help for humans. They don’t have a true effect on compiler interpretation. The following is perfectly valid for the compiler (but probably deserves programmer’s hand amputation)

evilSum: ((number) -> (number -> number))
evilSum = (+)

Type signatures are recommended but not mandatory. The compiler is always able to infer the situation.

Functions that are defined by you can be used only in prefix notation. Operators (eg < > =) are the only functions allowed to be used in prefix and infix notation (and yes like in math operator are functions too). Brackets are not used to define function input but to sort function evaluation and they are “dummy” alternatives to application operators |> or <| that are more convenient when you write your functional code as a pipeline.

infixNotation: Int
infixNotation = 5 + 5

prefixNotation: Int
prefixNotation = (+) 5 5

orderWithBrackets: Int
orderWithBrackets = mul 5 (sum 3 2)

orderWithLeftApplication : Int
orderWithLeftApplication = mul 5 <| sum 3 2

pipelineApplication: Int
pipelineApplication =
    2
        |> sum 3
        |> mul 5
    
composition: Int -> Int -> Int
composition =
    sum
        >> mul 5

Your code is not designed around “line number” or “step” concepts

Classic procedural/imperative languages are designed around Turing Machine architecture, to resume it shortly:

  • a program is a collection of instructions written on a paper roll (virtually infinite)
  • a “magic finger” points to/reads a line at a time on the paper roll
  • a memory stores data and tells the finger which is the paper row number to point on
  • each instruction is read, evaluated, it changes memory and defines the next row to read

A language like this can express relatively global or local memory scope. This allows changing things when something knowable at execution time only happens. Throw exceptions, jump to different “lines” of code, access/allocate/change memory, declare and init named references, add debug breakpoints. These features are useful but the compiler is unable to detect unpredictable and potentially destructive situations (runtime errors).

Well with Elm no

  • All the effects that can be generated inside Elm code are evaluable at compile time in term of type. The roll paper can be seen and evaluated in the whole from the first line to the last and you can’t compile code that may produce an unknown result or a type inconsistency.
  • Whatever it’s not knowable at compile time (eg an API call or non-elm code execution) is defined as a “side effect”. Side effect production is strongly handled by the architecture
    • you can produce a side effect result only in precise “moments” and the result will re-enter in your code in the future, in a moment, dedicated expressively for it.
    • you must handle and type properly your side effect result. You’ll have to handle and type both happy (your expected result) and unhappy (some type of error that could happen) possibilities.
  • The architecture is the “deus ex machina” that allows us to write something real with this paradigm. It slices your paper roll in discrete sheets (not accordingly line of code but accordingly programmer-defined states ) it evaluates the “sheet” in the whole like a huge big function, stores this result in internal memory, align the virtual dom (your view) and then waits quietly for the next message that will determine the next “state” and which “sheet” evaluate.

Ultimately Elm guarantees that your application, once booted, cannot never reach an unknown or partially inconsistent state that is not handled. It’s not possible to introduce true runtime errors inside your application besides architecture or compiler bugs (well you could always break the application deliberately in the browser). Even unexpected results from side effect will bring your application to an error state that must be handled.

Everything is created, nothing is transformed

  • Pass-by value is the only admitted
  • Change a data structure means recreate it interely. The notation to “assign” a value to a record
    setN: Int -> A -> A
    setN value a = 
        { a 
            | kn = value 
        }
    

    it’s just syntactic sugar to avoid you the boring enumeration of all record’s keys.

    setN: Int -> A -> A
    setN value a =
        { k1 = a.k1
        , k2 = a.k2
        -- ...
        , kn = value
        -- ...
        }
    

The type system is lean but expressive

Elm types are algebraic structures: a set with some operations (functions) on them. If this definition reminds you of some course of linear algebra you’re not wrong. In Elm you’ve only two ways to define your types:

Real type aliasing:

With the aliasing you can assign a referenced name to some other types, it allows us to do some things:

  • Redefine type name. It can be used to shorten long signatures (like generic/high order functions), writing it once for all, or define parametric types when you’ve generics (see the following sections)
    type alias MaybeInt = Maybe Int  
      
    type alias ABToC = MaybeInt -> MaybeInt -> Int
      
    type alias WithIntTuple a = (a, Int)
    
  • Define a record with a name and some named keys
    type alias A = { aValue: Int }
    
  • Use record type alias named reference to build rapidly a record from arguments (the order of arguments is relative to record keys)
      type alias A = { aInt: Int, aString: String }
      
      myA : A
      myA = A 5 "Ciao"
    

    Type aliases are “only” new names, useful to write shorten code but it’s only sugar syntax and it can be very misleading if used unproperly. You could think that you’re building a type-checked type but it’s not.

    type alias A = { v: String }
      
    type alias B = { v: String }
      
    pickV: A -> String
    pickV {v} = v 
      
    vOfA : String
    vOfA =
        A "Ciao A" |> pickV
      
    vOfB : String
    vOfB =
        B "Ciao B" |> pickV
      
    vOf : String
    vOf =
        { v = "Ciao ?"} |> pickV
      
    

    A, B and { v : String } are the same for the type system. The function pickV can accept a record typed A, B or even an unnamed record but formerly consistent with { v: String }.

    Similarly the following situation

    myA: B
    myA = A "A"
      
    myB: A
    myB = B "B"
    

    it’s still valid for the compiler.

    You’ll have to try to limit (or avoid totally) the construction of a record with type alias reference: it’s misleading as we’ve seen before (it’s not clear that you’re building a record) but it’s not scalable neither. When your record has several keys your constructor will be very unreadable (you may remember perfectly the key order but a colleague probably won’t).

You should also avoid type alias concrete types when this is not giving you a real advantage to your code, the aliasing hides the real type to the reader so someone could not be aware of which types lays behind your alias and as said before it doesn’t protect you from type abusing. Let’s see an example

  type alias Id = String
  type alias Slug = String
    
  type alias MyServerData =
    { id: Id
    , slug: Slug
    --...
    } 

Id and Slug are not allowing to control your data (everyone can still use String), you can’t express any constraint on these types or help a future developer (when a string is a valid slug? They must be treated in some different way? Where can I find this logic eventually?). If you need to express this kind of constraints you probably need a simple concrete type.

Concrete type:

Concrete types, or real types, are declared slight differently: type NameAlias = ConstructorTag <type1> <type2> ... a label or tag followed, eventually, by one ore more other types (primitive or not) that are able to store your data. They can be classified in 3 groups:

  • simple (not variant)
    type RGB = RGB Int Int Int
      
    type Id = Id String
      
    type Data = Data Id { rgbColor: RGB, description: String }
      
    myData: Data
    myData =
        Data 
          (Id "1") 
          { rgbColor = RGB 255 0 0
          , description = "my data with red color" 
          }
    
  • unions (not intersecting subsets)
    type Pantone
      = Red032C
      | Red485C
      
    type ColorSchema
      = RGB Int Int Int
      | HEX String
      | CMYK Int Int Int Int
      
    toRgbColorSchema: Pantone -> ColorSchema
    toRgbColorSchema pantone = 
      case pantone of 
          Red032C ->  RGB 239 51 64
      
          Red485C -> RGB 218 41 28
    
  • mutually recursive unions
    type StringBinaryTree
      = Node String StringBinaryTree StringBinaryTree 
      | Leaf String
    

The labels are constructor functions of the type and they can be directly used only if the type is explicitly exposed by the module where you placed them (this feature allows to restrict type handling and manipulation). Real types are strictly type-checked, two types with different names are different even if they are structurally the same and cannot be used on functions that are not designed for them.

Limited polymorphic

The only allowed form of polymorphism is parametric polymorphism, similar to diamond <T> of other languages. It can be expressed with lowercase named reference in type declaration (or function signatures). Use parametric types allows reusing your code nicely.

type BinaryTree a 
    = Node a (BinaryTree a) (BinaryTree a) 
    | Leaf a

type alias RecordWithA record =
    { record
        | a : String
    }
    
reusableView: msg -> Html msg
reusableView onClickMsg =
    div [ onClick onClickMsg ] [ text "Click on Me!"]

The abstraction must be resolved at compile time to be valid. It means that the code must explicit the generic type at some point to compile.

type alias ParentModel =
    { intBinaryTree: BinaryTree Int
    , stringBinaryTree: BinaryTree String
    , recordWithABC: RecordWithA { b: String, c: Int }
    }
    
parentView: Html Msg
parentView =
    div [] [ reusableView ParentMsg.Click ]

Remember that subtyping is not allowed (this is the price for type inference). Any attempt to “generify” using a parametric type, even in a safe way, will be blocked by the compiler.

type AFamily
    = A (RecordWithA {})
    | WithB (RecordWithA { b : Int })


type alias RecordWithA record =
    { record
        | a : String
    }

-- This function will throw a compiler error because the compiler will type the function output and it will signal the discrepancy in type.
-- If you keep in mind that type signatures are not used by the compiler this is more understandable.

generifyAFamily : AFamily -> RecordWithA record
generifyAFamily a =
    case a of
        A record ->
            record

        WithB record ->
            record


pickA : AFamily -> String
pickA =
    generifyAFamily >> .a

function overloading (Ad hoc polymorphism) is not allowed either.

It looks like Haskell

If you’re a Haskell developer you may think that Elm it’s just a Haskell dialect with a simpler syntax but it’s an error. There are a lot of differences between these two but to resume:

  • Native types are implemented in javascript due to performance reasons (eg. String it’s not a List Char)
  • Polymorphism is highly limited. Typeclasses can’t be implemented outside kernel code. Accessible type-class are just a few: number, comparable and appendable and they are convenient to be used only in heavy-abstract functions.
  • It’s not possible (since the 0.19 release) to define or extend custom operators
  • Elm code is not lazy evaluated by default, the way to make your function lazy is different and not easy to understand. Write recursive algorithms in Elm needs some attention.

Robust but free-form

At the net of all these features, the code developing in Elm is fairly less opinionated.

The developer has a huge control, and responsibility, over the model architecture and code organization. Add new features on the codebase and refactor old parts are in general secure and relatively simple tasks. Any part of code that you may have forgotten will be noticed by the compiler.

The capability to express scalable and strongly declarative architectures is the Elm true force. The need to isolate API communication makes the isolation (or exclusion) of business logic profitable. For the same reason is much convenient to re-build your data in the form that is most suitable for the frontend (and not for the database) and in general express your types and functions in a high-level format. Definitely, this is what makes Elm development not only educative (you won’t write procedural code in the same way) but even funny.

Target acquired: become a Wise Architect

Web development is not famous for the organized planning of software architecture. Services evolve rapidly following Agile’s methods, solutions and architectures tend to become rapidly non-sufficient and untidy due to overgrowing. If this is true in general it’s even more true on frontend: new flows, SEO requests, ABTesting, graphic changes, experimental UX, tracking… are all requirement that often born from non-technical teams and often developed as semi-prototypes that will last forever. It becomes important for an evergreen project to be able to develop architectures that can be flexible, scalable, clear and talkative.

Elm is a powerful tool to reach this target but it can be hard to be able to use it properly. In the next articles we’ll see how to recognize problems and anti-patterns, why we should avoid them and how to replace them with more readable and maintainable code.

Ivan Gori

HCI obsessed, JS experimentalist, 3D enchanted and actually Elm apprentice