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.
Introduction: 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?
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 ifgreetingCard
isNothing
orJust
. 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 aMaybish
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 recorddummyFormValid
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 updateValidatable
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
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.