MEGA: Make Elm Great Again logo made with tangram
MEGA: Maybe Maybe is not the right choice

MEGA: Maybe Maybe is not the right choice

In this article of MEGA I’ll talk about some useful patterns and personal thought about Maybe types in Elm.

Ivan Gori
Published in
13 min readJun 29, 2021

--

Introduction: What is a Maybe?

Question mark made with tangram pieces
What is a Maybe ?

In Elm you don’t have the concept of undefined, you can't do things like define an optional key in a record or runtime declaration but there is the concept of null. The most common scenario can be the result of an API call that must be saved in a key inside your model but since you don't have the result at init you're forced to declare it as a Maybe and initialize your model with a Nothing until the API call will assign the proper value to it. Under the hood, a Maybe is quite different from other languages equivalents, even javascript, mainly because Elm doesn't have implicit null references.

I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

The inventor of null references, Tony Hoare

If in the most common languages the access of a null variable is legit, with a Maybe you (always) must address the unavailability of the value.

Avoid Maybes: how and why

The nullable concept is quite common and familiar in programming but in Elm, it tends to become insidious. The enforcement to always handle the possibility that a Maybe is not valued properly tends to make your code rapidly unclear and less scalable. You should always think twice before wrapping your type inside a Maybe because it will probably hide some architectural deficiencies that can be addressed more elegantly and expressively.

Every Maybe it’s like a spoon of baking powder in your cake

As we said before in a common imperative language you’ll probably be able to handle your nullable with exceptions and some if and despite all you can write your code without worrying about it because you are quite sure that the value will never be null in that part of the code.

In Elm’s world every time you need to use a Maybe you must handle both the valued and not valued case. For each Maybe your function will become less readable stuffed of withDefault or maybeMap and it will soon rise, doubling its length for each nullable data.

Can you build a house with Maybe bricks?

A house made with tangram with a missing piece

It probably sounds banal to say but if a Nothing means an Error or a data inconsistency then that result mustn't be a Maybe! This is a quite common scenario in your API that are written in other languages and where databases are stuffed with nullable fields due to relations with other tables or progressive entity finalization.

Let’s make an example with a native library, the side-effect function Dom.getElement that tries to find an element with a specific id in your DOM.

getElement : String -> Task Error Element
getElement =
Elm.Kernel.Browser.getElement

The element could not be there but it will be an error trying to find an element that is not rendered. You can notice that the result is not a Maybe Element but an Error or Element that allows you to track the error case explicitly in your update cycle.

Our API (and our decoders too) should act similarly: or our data are correctly evaluated for the purpose of the API or the whole thing must fail. You should not allow your API to emit implicitly nullable data that will make the consumption of the API impossible, it’s like hiding the dust under the carpet.

Does have any sense a nullable price into a payment page API only because the price field is nullable in the database (probably because is evaluated by a second microservice in a delayed moment)? No, not for the payment page. If the price is not available for unknown reasons the page can’t show properly so it must be broken!

👎🏻

type alias PaymentData =
{ price: Maybe Float
, products: List Product
}

👍🏻

type alias PaymentData =
{ price: Float
, products: List Product
}

In a future article, we’ll see some useful tricks to empower decoders allowing us to handle all the problematic cases of a bad structured API but is just a patch on a bigger problem. You should argue with API maintainers if something smells.

They’re coming outta the godd** walls!

Maybesw are like rabbits, they multiply and despite our efforts, there are some cases where we can’t avoid to declare some Maybe data in our model. In such cases, we should analyze the reason behind that proliferation and how to reduce it.

Often a lot of data are nullable for the same reason or “case”, in such situation it can be handy to group them all in a single nullable data that store them all in their valuated types. Some examples of that are a group of fields in a form or some data needed by an optional part of your view.

👎🏻

type alias Gift =
{ hasGreetingCard: Bool
, greetingCardTitle: Maybe String
, greetingCardBody: Maybe String
, greetingCardSignature: Maybe String
--
}

👍🏻

type alias Gift =
{ greetingCard: Maybe GreetingCard
--
}

type alias GreetingCard =
{ title: String
, body: String
, signature: String
}

Such an approach has different advantages:

  • we’ve logically grouped all the fields related to GreetingCanrd in a dedicated type. This makes the model more expressive and easy to understand.
  • we’ve reduced the key length name and relative functions (greetingCard.name)
  • we can make a GreetingCard module that defines the type and stores all related functions that are based on its evaluated case
  • we’ve removed a redundant flag hasGreetingCard that can be replaced with a simple function that checks if greetingCard is Nothing or Just. We should always prefer functions over the stored data when it's possible. It's much more scalable and allows us to change a single point (our function) in future refactorings or when your logic changes. If the type is opaque is even better.

Maybe it’s a very private person

In general a Maybe says a lot about how we must handle it but nothing about why it's there: when, how and why that value can be null ?

Semantically it’s a simple union type that doesn’t make the case talkative and that’s the reason why understanding the logic behind it’s difficult. If in a common imperative language this is commonly acceptable in Elm where you can write strongly declarative types easily it’s not.

type alias Model = 
{ isPrivacyAccepted: Maybe Bool
}

That’s a simple example, here we know that isPrivacyAccepted flag can be Nothing Just True or Just False.

But what does a first-time programmer can imagine?

  • Probably there is a checkbox that can’t be visible/initialized under some circumstances
  • Maybe it’s used to model a “tri-state” checkbox
  • The data comes from the backend but only in some cases

Even with such a simple example, we can have a lot of ambiguity.

Let’s try to use a different type:

type alias Model = 
{ privacyConsent: Consent
}

type Consent
= Assent
| Denial
| NotExplicitAnswered

Now is much more clear: that key is probably an answer to a tri-state checkbox. And it makes clear that a user could also not express clearly his consent to privacy data treatment. Pay attention, this type doesn’t resolve the Maybe but includes it inside its variants.

Important things can’t be Maybe

Let’s go back to the Gift example

type alias Gift =
{ greetingCard: Maybe GreetingCard
-- ...
}

type alias GreetingCard =
{ title: String
, body: String
, signature: String
}

Now let’s imagine that the presence or absence of a GreetingCard will change dramatically our application. A gift with a greeting card produces a dramatically different view, with specific messages, API calls, validations... As a direct result, your code will start to grow because you'll need to check on that value in a lot of different points and this will make everything less declarative, readable and difficult to maintain.

In such cases rewrite the whole Gift as an union type may be an interesting solution:

type Gift 
= Simple GiftData
| WithGreetingCard GiftWithGreetingCardData

During the init there will be a creator function for the Gift type that will have the ownership to create Simple or WithGreetingCard variant based on the raw data. You'll indeed have probably a bunch of redundant functions (but they will be small and simple) but now you've strictly enforced all your flow. Now every part of your code will have to handle in a strictly different way the two cases. You'll probably notice that isolate such logics (update/view/helpers...) in functions that work only on one type is much more convenient and you can store them in their named modules. Gift's module will take care of hiding the implementation logic working as a "logic hub".

view: Gift -> Html Msg
view gift =
case gift of
Simple data -> GiftData.view data
WithGreetingCard data -> GiftWithGreetingCard.view data

As a final result, you’ll have 2 sub-applications smaller, simpler and more scalable.

It’s a great step forward to remove only one Maybe, isn’t it?

Maybe’s friends

There are some types and patterns that hide Maybe, we could call them Maybish. The Consent type seen before is an example. Collections ( List and Dict) are a common family of types that hide Maybe. We know that picker functions on these types will always return a Maybe a because collections can be legitimately empty. It would be an error to write this in Elm:

type alias Model = 
{ cryingList: Maybe (List (Maybe Int))
}

addElement: Int -> Model -> Model
addElement el model =
{ model
| cryingList = Maybe.map (List.append [Just el]) model.cryingList
}

sumToAll : Int -> Model -> Model
sumToAll addend model =
{ model
| cryingList = Maybe.map (List.map (Maybe.map ((+) addend))) model.cryingList
}

So if a collection can be legitimately empty you should think carefully about using a simple List if your model or logic needs almost one element to work properly. A more safe model could be the following:

type ValuedList a = 
ValuedList
{ first: a
, remaining: List a
}

toList: ValuedList a -> List a
toList (ValuedList {first, remaining}) =
first :: remaining

head: ValuedList a -> a
head (ValuedList { first }) =
first

This type is able to guarantee that almost one element is present (common List.head can't).

Don’t hide a Maybe under the carpet

If despite all your attempts the Maybe is still there well then respect it. If you’re writing highly reusable code then try to write functions that work directly on the concrete case.

There are two main reasons behind this choice:

  • you can’t write your logic without another Maybe or a Maybish situation
  • you can’t predict how the user will handle the Nothing

It's much more convenient to allow the consumer of your code to handle the Nothing case by itself and don't take care of it and all your code will become much easy to write and understand.

Let's think about the ValuedList creation function:

👎🏻

valuedList: List a -> Maybe (ValuedList a)
valuedList list =
case list of
(x::xs) -> Just (ValuedList { first = x, remaining = xs })

_ -> Nothing

👍🏻

valuedList: a -> List a -> ValuedList a
valuedList first rest =
ValuedList { first = first, remaining = rest }

-- Helper but not the main way to consume your type
fromList: List a -> Maybe (ValuedList a)
fromList list =
case list of
(x::xs) -> Just (ValuedList { first = x, remaining = xs })

_ -> Nothing

The main creation function asks explicitly for head and tail parts. The fromList is just a convenient helper for some cases.

A case study: Form and validations

Our forms are probably Maybe’s best friends, the whole html input logic is based on nullable values (text, input, select)

type alias FormData = 
{ name: Maybe String
, selectedOption: Maybe Option
, availableOptions: List Option
, privacyConsent: Consent
}

What happens when we need to send this data through an API? What happens if we would need to validate such data to ensure that they are not empty or that they’re properly selected? The simplest approach is to create a function that can express the client-side validation

validations: List (FormData -> Bool)
validations =
[ .name >> isJust
, .selectedOption >> isJust
]

isValid: FormData -> Bool
isValid =
List.map validations
|> List.all identity

In our update, we’ll probably have an if that will trigger the post only if FormData is valid

update: Model -> Msg -> (Model, Cmd Msg)
update model msg =
case msg of
Submit ->
model
|> withCmds [
if FormData.isValid model.formData then
postFormData model.formData
else
Cmd.none
]

It’s simple, but we’ve only moved the problem. In our function postFormData we'll have to handle the encoding of name and selectedOption null cases. We could use a Maybe.map2 or some empty strings/default cases. What happens if FormData will grow or become more nested? What happens if we need to make some logic that works only on validated data? Again our Maybe it's not scalable. The solution in such a case can be a type able to store both the raw and the validated version of our formData. Let's try to write an abstract component to help us:

type Validatable raw valid
= NotValidated raw
| Validated valid


create : raw -> Validatable raw valid
create raw =
NotValidated raw


validate : (raw -> Maybe valid) -> Validatable raw valid -> Validatable raw valid
validate validatorFunction validatable =
case validatable of
NotValidated raw ->
validatorFunction raw
|> Maybe.map Validated
|> Maybe.withDefault validatable

validated ->
validated

update: (raw -> raw) -> Validatable raw valid -> Validatable raw valid
update rawMap validatable =
case validatable of
NotValidated raw -> NotValidated (rawMap raw)

validated -> validated


map : (valid -> c) -> Validatable raw valid -> Maybe c
map mapper validatable =
case validatable of
NotValidated _ ->
Nothing

Validated valid ->
valid
|> mapper
|> Just

isValidated: Validatable raw valid -> Bool
isValidated validatable =
case validatable of
Validated _ -> True

_ -> False

Now we need to define the data model of the form. Let’s make our MyForm module

type alias MyForm = Validatable FormRaw FormValid

type alias FormRaw =
{ name: Maybe String
, surname: Maybe String
}


type alias FormValid =
{ name: String
, surname: String
}

setName: Maybe String -> MyForm -> MyForm
setName value =
Validatable.update (\raw -> {raw | name = value })

setSurname: Maybe String -> MyForm -> MyForm
setSurname value =
Validatable.update (\raw -> { raw | surname = value })

now we’ll write the most important function: validator with the use of Maybe.Extra library. Validator is a function that will try to apply a sequence of changes to a dummy version of FormValid in order to try to create a FormValid from FormRaw.

dummyFormValid: FormValid
dummyFormValid =
{ name = ""
, surname = ""
}

validator : FormRaw -> Maybe FormValid
validator raw =
Just dummyFormValid
|> MaybeExtra.andThen2 (\v partialValidated -> Just { partialValidated | name = v }) raw.name
|> MaybeExtra.andThen2 (\v partialValidated -> Just { partialValidated | surname = v }) raw.surname

Some remarks:

  • dummy is needed to don't hurt the compiler since we can't dynamically extend a record
  • dummyFormValid could be a little smarter and initialize also all that fields that won't be validated and can be copied directly fromFormRaw
  • each mapping function of andThen2 is a validator function of the single field with a type signaturefieldValidator: FieldValueType -> FormData -> Maybe FormData that in our case is quite simple but could eventually make some extra checks over the field and eventually returnNothing if is not passed
  • with a little effort we could rewrite this pipe with a fold on a list of functions as we’ve seen in the previous example

Finally, we can expose this last function of MyForm

validatedCmd: (FormValid -> Cmd msg) -> MyForm -> Cmd msg
validatedCmd cmdMap =
Validatable.validate validator
>> Validatable.map cmdMap
>> Maybe.withDefault Cmd.none

It’s done. Now we can use our side effects that will work only on FormValid (setter/mapper functions are omitted)

update: Model -> Msg -> (Model, Cmd Msg)
update model msg =
case msg of
SetName val ->
model
|> Model.updateMyForm (MyForm.setName val)
|> withoutCmds
SetSurname val ->
model
|> Model.updateMyForm (MyForm.setSurname val)
|> withoutCmds
Submit ->
model
|> withCmds
[ MyForm.validatedCmd postFormValid model.myForm
]

Some other remarks:

  • the validated form state is not stored on the model to avoid inconsistencies. It’s not allowed the possibility to update a field once the form has been validated (you should define a function valid -> raw to go back from aValidated state)
  • Validatable.validate always need our validator that probably will never change. Maybe it would be convenient if Validatable would be able to validate (or invalidate) by itself on each update
  • Validatable could include another variant to express the condition of “Not yet validated”, maybe we’ll like to show some validation messages only after a first submit attempt
  • How we could express the validation logic for each field if we’d like to show some messages under each form field?
  • validatedCmd looks like a special mapper isn’t it? Maybe we could make an abstract one for other purposes

I leave all these observations without an answer hoping that your phantasy and talent will find some interesting solutions.

Finally, the end

super mario dramatic end of level

I hope I’ve managed to give you a more clear vision behind the reasons to avoid Maybe abusing. Keep in mind that some data inconsistency is however inevitable but being able to reduce it will improve your code. The next time you’ll be tempted to write Maybe on your keyboard hold on for a while, think if is needed and if you can spare.

Originally published at https://prima.engineering.

--

--

Prima Engineering
Prima Engineering

Published in Prima Engineering

We are a fast growing web company based in Milan (Italy); we love functional programming and cutting-edge technologies such as AWS, Docker, RabbitMQ, Elm, React and so on. We are among the first in Italy running Elixir in production.

Ivan Gori
Ivan Gori

Written by Ivan Gori

HCI obsessed, JS experimentalist, 3D enchanted and actually Elm apprentice — https://github.com/kioan000