In Elm code, "if it compiles, it works." Does that come for free? Let's see if we can find Elm code where "it compiles, but it doesn't work" to see what we can learn about writing maintainable Elm code.
Do violins make beautiful sounds? As a non-violinist who has tried a violin, I'm living proof that sometimes the sounds they create are not so beautiful. But there's plenty of proof that they make beautiful sounds as well.
Does a vuvuzela make beautiful sounds? I don't think it's controversial to say that it's probably not even possible. So violins are something special without a doubt. While a violin can't make beautiful sounds without the right technique, it is equipped with several tools that give it unique expressive power. By changing the angle of the bow, how close to the bridge you play, the speed of the bow, and by moving our fingers on the fingerboard (vibrato, intonation), there is as much expressive power as almost any musical instrument in the violin.
Elm doesn't give you safe code for free. It does help you avoid several footguns through its explicit semantics and strict type system. But just as importantly, it provides tools for you to express the constraints of your system. And when you express those constraints in Elm, you can really trust them. There is no way to bypass the constraints or fool the type system. So having the expressive power of a vuvuzela will limit you. But tools are only as good as our ability to make use of them. Elm's guarantees are 100% not 99%. But it can't enforce constraints that are specific to your domain. So remember that it's up to you to write custom tailored guarantees for your domain. As an Elm developer, you're like the violinst - you have a beautiful tool in your hands but its up to you to use it to its full potential.
To make the most of these tools for enforcing constraints, lets see what happens when we don't make use of them. Lets try to pinpoint the key techniques for leveraging those tools to make Elm code feel like "if it compiles, it works."
username -- this should never happen |> Maybe.withDefault ""
I'm guilty of writing code like this. And if you're like me, you know the feeling of spending too much time debugging a problem only to find a line of code like this and realizing that you should always expect the unexpected.
This can be particularly elusive when these default values trickle their way through the system to end up in states that otherwise seemed impossible. It can also lead to silent runtime failures instead of a descriptive compiler error (inexhaustive case errors, type errors, etc.).
That can mean the difference between Elm being a helpful and alert assistant and Elm becoming a lazy assistant. Elm can only help you enforce the constraints you tell it about.
Also be on the lookout for catch-all clauses like this in your code:
type Membership = Pro | Free case membership of Pro -> "Pro" _ -> "Free"
This will fall through to the correct value now, but if you add a new
Membership level then you'll end up with code that "compiles, but doesn't work."
type Membership = Pro + | Premium | Free
Note that catch-all's can be what you want in some cases, so you need to use your judgement to make sure it's what you want. For example, perhaps an
isFree : Membership -> Bool could safely use a catch-all. Though the cost of handling it explicitly is low, and there's always a risk that the catch-all could cause you to miss something important (like adding a
FreeTrial level of
As an alternative to default values, squelched errors, and catch-alls, you can consider using
Debug.todo statements as a placeholder for unhandled cases during development. Then you are reminded to handle those cases before your code goes live (since
elm --optimize will fail if there are any uses of
If you've ever iterated on writing an
Decoder then you know that you can easily write JSON Decoders (or encode JSON values) where it "compiles, but doesn't work." That's because within the local scope of your Elm application everything is consistent, and you've handled the possible errors in your Decoding or HTTP requests.
Using [Types Without Borders] can help avoid this case of "compiles, but doesn't work," by keeping your types in sync across the boundaries of your frontend. Some helpful tools for that include:
This topic comes up a lot in the Elm world, so by now we have plenty of great resources that show how these Impossible States can cause our code to "compile, but not work."
So be sure to [Make Impossible States Impossible]. Reduce states to valid states as much as possible. Keep in mind that it's not just about valid combinations of state. Make the smallest amount of state possible to express (and no less). A useful technique is to count the cardinatlity of your types. You can construct a table of values you can express with your types to help identify invalid ones.
The code smell often referred to as Primitive Obsession is when values like String or Int are over-used. Under the hood, you'll need Strings and Ints, but you can give better semantics and enforce more constraints if you create new Custom Types to wrap those primitives. For example, instead of a
String we could use
type Username = Username String and instead of an
Int we could use
type UserId = UserId Int.
Here's some code that "compiles, but doesn't work" because it allows an
Int instead of a more constrained Custom Type:
getUserProfileInfo : (Result Http.Error User -> msg) -> Int -> Cmd msg update msg model = -- ... ( model, getUserProfileInfo GotUserProfile product.id )
If we use a Custom Type, we could turn this into "it doesn't compile, so it doesn't work":
getUserProfileInfo : (Result Http.Error User -> msg) -> UserId -> Cmd msg update msg model = -- ... ( model, getUserProfileInfo GotUserProfile product.id -- compiler error -- need to use model.currentUser.id to fix it )
Don't forget to [Wrap Early, Unwrap Late] - the goal is to have the best representation of our data for the entire life of the value.
UserId like the example above only helps us if we use an Opaque Type to help us enforce the constraint. If we can use
UserId product.id then there's not much improvement.
An Opaque Type helps you reduce the surface area where you need to think about a constraint ([Opaque Types Let You Think Locally]). That means you can trust that a
UserId is what it says it is.
Use Opaque Types to help enforce constraints about:
For example, if a username must be non-empty, representing usernames as
Strings or as a Custom Type that can be freely created outside of a Username module (non-opaque types) means you need to be careful about that constraint everywhere in your codebase.
By hiding the constructor from outside of the
Username module, you can enforce that guarantee in one place and have confidence in that guarantee everywhere outside of that module.
module Username exposing (Username, fromString) type Username = Username String fromString : String -> Maybe Username fromString rawUsername = if isValid rawUsername then rawUsername |> Username |> Just else Nothing isValid : String -> Bool
If you need to be careful about upholding constraints on a huge surface area in your codebase, then you won't be able to make changes with confidence. If you have a small surface area with clear responsibilities enforced within opaque types, then you can be confident the constraints will still be enforced when you change code outside the Opaque Type's module.
We're still human, and we can still introduce bugs. And these techniques work best in tandem with automated tests, not instead of tests. But if you keep these techniques in mind, you'll get the feeling of making bulletproof changes to code that comes from using the tools Elm gives us to their full potential.
Sign up to get my latest Elm posts and course notifications in your inbox.
Pure Elm content. Unsubscribe any time.
Parse, Don't Validate
Opaque Types Let You Think Locally
Elm's Opaque Types are a powerful tool for narrowing the surface area where you check a constraint. TypeScript's Branded Types give similar functionality but...
Wrap Early, Unwrap Late
Model data with its ideal form. The sooner you can get it into its ideal type, the better. The later you can turn it into a non-ideal type, the better.
Make Impossible States Impossible
A data modeling philosophy. Start by identifying all the possible states your type can express, and then remove the ones that represent invalid states.
Types Without Borders