MEGA : Make Elm Great Again
An introduction article about Elm development and MEGA series. Why we use it in Prima and why you should be interested in Elm.
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 designed for beginners but could prove useful for everyone as a general explanation of a language that you could fairly define exotic. The purpose is just to make a summary 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 get 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 programming 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 from TEA but without 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 file that contains all your working code, barely readable and highly optimized. This bundle can be directly included in some external bundle written by you.
The compiler can be launched directly via CLI or with a bundler (like webpack) plugin that targets .elm files. Using these plugins allows you to import and use your main Elm files directly in your javascript source like any other javascript module and makes 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 travelers
Functions are the main skeleton and probably the only. Any nominal reference inside Elm code is 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
andB
returnsC
- given
A
returns a fuctionB -> C
Type signatures are a read help for humans. 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 but if you use them remember that they are right associative so consider to use brackets to declare high order function types.
-- Ok
sum: Int -> (Int -> Int)
sum = (+)
-- Ok but less readable
sum2: Int -> Int -> Int
sum2 = (+)
-- Ok - automatic type inference
mapNoTS mapper = mapper 5-- Ok
map: (Int -> Int) -> Int
map mapper = mapper 5-- Error
mapWrongTS: Int -> Int -> Int
mapWrongTS mapper = mapper 5
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 operators 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, then 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 “state”, 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 its result in internal memory, aligns the virtual dom (your view) and then waits quietly for the next event that will determine the next “state” and which “sheet” evaluate.
Ultimately Elm guarantees that your application, once booted, would 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"
is still valid for the compiler.
You should 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 does 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!"]
Remember that runtime evaluation is not admitted. 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 ]
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 is 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 natively (in Javascript) due to performance reasons (eg.
String
it's not aList 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 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. Adding new features on the codebase and refactoring 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 or BE stack) 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.
Originally published at https://prima.engineering.