Deriving Nullable
Deriving Nullable
In the first exercise in this chapter you should have created an instance of
Nullable
for Maybe
and list values. There are a few ways that you could have
approached writing these instances, but let’s look at some reasonable
definitions you might have used:
module DerivingNullable where
import Prelude hiding (null)
import qualified Prelude (null)
class Nullable a where
isNull :: a -> Bool
null :: a
instance Nullable [a] where
= Prelude.null
isNull null = []
instance Nullable (Maybe a) where
Nothing = True
isNull = False
isNull _ null = Nothing
These instances use a fairly intuitive definition of what should be considered
null
: empty lists are null, as are Nothing
values. What if we have an
optional list though?
λ isNull Nothing
True
λ isNull []
True
λ isNull (Just [])
False
In this case it’s not clear whether Just []
should be considered a null value
or not, it depends entirely on the program we are writing. You can even imagine
that we might want different behavior in different parts of the same program.
In this exercise, try to create an API so that a user can make use of deriving via
to create Nullable
instances of their own types. A user should be able to
decide whether Just []
should be considered a null value or not by selecting
which type they derive their instance from.
Hints
Click to reveal
If you’re having trouble imagining how an API like this might be used, imagine that you’re working with some text data that will be provided by user and you need to decide whether a user provided a value or not. In some cases, an empty string might be a valid input when the user doesn’t have anything more meaningful to input. In other cases, you might want to ensure that they’ve provided some actual data.
For example, imagine that you wanted to define two types: OptionalString
and
OptionalNonEmptyString
. You might start by defining them like this:
newtype OptionalString = OptionalString { getString :: Maybe String }
deriving stock (Eq, Show)
newtype OptionalNonEmptyString = OptionalNonEmptyString { getNonEmptyString :: Maybe String }
deriving stock (Eq, Show)
Instead of writing instances manually, think about how you could provide a way
for users to use deriving via
with these types.
Click to reveal
You’ll need to create a newtype
for each of the behaviors you want to make
available with deriving via
, along with a Nullable
instance for each.
Click to reveal
Trying creating two types with Nullable
instances. First, create a type named
BasicNullable
that should have isNull
return True
only if there’s a
missing value. Next, create one called TransitiveNullable
with a definition of
isNull
that will return also true if the inner value is null.
Solution
Click to reveal
We’ll start our solution by creating a new module and re-defining
Nullable
. We’ll go ahead and add the DerivingVia
extension too, since we’re
planning to use it later in this exercise:
{-# LANGUAGE DerivingVia #-}
module EffectiveHaskell.Exercises.Chapter6.DerivingNullable where
import Prelude hiding (null)
class Nullable a where
isNull :: a -> Bool
null :: a
Next, let’s define some types that represent the different “templates” that we
might want to use with deriving via
to get our behaviors. If you looked at the
hints earlier, you’ll know that we’re going to create two types: BasicNullable
and TransitiveNullable
. Let’s start with BasicNullable
first.
We want our BasicNullable
type to represent optional values that are only
considered null if they are truly missing a value. Let’s start by creating the
type. We’ll also add a Show
instance to make things easier when we want to
test the code in ghci
later:
newtype BasicNullable a = BasicNullable (Maybe a)
deriving stock Show
We also need to create a Nullable
instance for BasicNullable
. For this basic
definition of Nullable
, we’ll consider a Nothing
value to be null, and
anything else will be non-null:
instance Nullable (BasicNullable a) where
BasicNullable Nothing) = True
isNull (= False
isNull _ null = BasicNullable Nothing
Next, let’s do the same thing for TransitiveNullable
. Unlike BasicNullable
,
this instance will only consider something non-null if it contains a non-null
value:
newtype TransitiveNullable a = TransitiveNullable (Maybe a)
deriving stock Show
instance Nullable a => Nullable (TransitiveNullable a) where
TransitiveNullable Nothing) = True
isNull (TransitiveNullable (Just a)) = isNull a
isNull (null = TransitiveNullable Nothing
Even though we created BasicNullable
an TransitiveNullable
so that we could
use them with deriving via
, they are still valid ordinary types and we can
test their behavior in ghci
. Let’s run a few tests to make sure everything’s
working as we expect. Let’s start by testing out the isNull
instance of
BasicNullable
:
λ isNull $ BasicNullable Nothing
True
λ isNull $ BasicNullable (Just "hello")
False
We can see from these examples that isNull
appears to be working for the
obvious cases, but we should still test that isNull
correctly returns False
when we have Just
some empty value. One way we can test this is to nest some
BasicNullable
values:
λ isNull $ BasicNullable (Just $ BasicNullable Nothing)
False
This is a little weird though. Let’s add an instance of Nullable
for lists so
that we have some values to test with:
instance Nullable [a] where
= True
isNull [] = False
isNull _ null = []
Using these two instances, we can see that while []
is null, BasicNullable (Just [])
continues to be treated as non-null:
λ isNull []
True
λ isNull [1,2,3]
False
λ isNull $ BasicNullable (Just [])
False
Now that we’ve figured out BasicNullable
let’s move on to
TransitiveNullable
. Just like before, we’ll start by creating a new type:
newtype TransitiveNullable a = TransitiveNullable (Maybe a)
deriving stock Show
We’ll also create a new instance of Nullable
. Unlike our earlier instance,
we’ll need to make sure that whatever type we’re holding is also Nullable
since we’ll need to check to see if the values we’re working with are null or
not:
instance Nullable a => Nullable (TransitiveNullable a) where
TransitiveNullable Nothing) = True
isNull (TransitiveNullable (Just a)) = isNull a
isNull (null = TransitiveNullable Nothing
Like before, we can load this up into ghci
to test it:
λ isNull $ TransitiveNullable (Just "hello")
False
Unfortunately, if we try to test the empty case of TransitiveNullable
the same
way we tested BasicNullable
we’ll get an error:
λ isNull $ TransitiveNullable Nothing
<interactive>:20:1: error:
• Ambiguous type variable ‘a0’ arising from a use of ‘isNull’
prevents the constraint ‘(Nullable a0)’ from being solved.
Probable fix: use a type annotation to specify what ‘a0’ should be.
These potential instances exist:
instance Nullable (BasicNullable a)
-- Defined at EffectiveHaskell/Exercises/Chapter6/DerivingNullable.hs:12:10
instance Nullable OptionalNonEmptyString
-- Defined at EffectiveHaskell/Exercises/Chapter6/DerivingNullable.hs:35:12
instance Nullable OptionalString
-- Defined at EffectiveHaskell/Exercises/Chapter6/DerivingNullable.hs:31:12
...plus two others
(use -fprint-potential-instances to see them all)
• In the first argument of ‘($)’, namely ‘isNull’
In the expression: isNull $ TransitiveNullable Nothing
In an equation for ‘it’: it = isNull $ TransitiveNullable Nothing
The problem here is the definition of Nullable
we defined for
TransitiveNullable a
relies on the definition of Nullable
for
a
. When we use a value like Just "hello"
the compiler can infer the type of
a
must be String
. When we use Nothing
we’re not giving the compiler enough
information to figure out what a
should be, so it can’t pick a Nullable
instance. We can help it out by adding a visible type application to
TransitiveNullable
to tell it what type to use for a
:
λ isNull $ TransitiveNullable @String Nothing
True
Alternatively, you can add a type annotation to Nothing
:
λ isNull $ TransitiveNullable (Nothing :: Maybe String)
True
Now that we’ve covered both obviously null and obviously non-null cases, let’s
take a look at an example where TransitiveNullable
and BasicNullable
should
differ: A value that contains an empty list. Let’s test them side-by-side:
λ isNull $ TransitiveNullable (Just [])
True
λ isNull $ BasicNullable (Just [])
False
As expected, the TransitiveNullable
instance considers an empty list to be
null, while the BasicNullable
instance doesn’t.
Now that we’ve created two different types that have our desired Nullable
behaviors, how can we use them with deriving via
? To start with, let’s imagine
that we’re dealing with some text data. As a specific example, imagine that
you’re processing some data submited by a user, and you want to ensure that
you’re getting valid data. In some cases, you might have data that could be
empty, while in other cases you want to ensure that there’s actual data. For
example, a user signing up for a service might be required to enter a password,
but the “how did you hear about us” field could be left empty.
We’ll represent these two types of data with the types OptionalString
and
OptionalNonEmptyString
:
newtype OptionalString = OptionalString { getString :: Maybe String }
deriving stock Show
newtype OptionalNonEmptyString = OptionalNonEmptyString { getNonEmptyString :: Maybe String }
deriving stock Show
You can imagine that we could write Nullable
instances for these two types
that are identical to the BasicNullable
and TransitiveNullable
types we just
created, but thanks to deriving via
we don’t need to. Instead, we can add the
DerivingVia
extension, and use it to select an instance:
newtype OptionalString = OptionalString { getString :: Maybe String }
deriving stock Show
deriving Nullable via BasicNullable String
newtype OptionalNonEmptyString = OptionalNonEmptyString { getNonEmptyString :: Maybe String }
deriving stock Show
deriving Nullable via TransitiveNullable String
Let’s load this code up into ghci
and check that it behaves like we’d
expect. Starting with OptionalString
:
λ isNull $ OptionalString (Just "hello")
False
λ isNull $ OptionalString (Just "")
False
λ isNull $ OptionalString Nothing
True
As you can see, since we derived the Nullable
instance for OptionalString
from BasicNullable
, the behavior is the same. Next, let’s look at
OptionalNonEmptyString
:
λ isNull $ OptionalNonEmptyString (Just "hello")
False
λ isNull $ OptionalNonEmptyString (Just "")
True
λ isNull $ OptionalNonEmptyString Nothing
True
Success! It appears that OptionalNonEmptyString
is now using the same behavior
as TransitiveNullable
.
You might have noticed that, in practice, using deriving via
in this case
didn’t buy us much- it seems as though we’ve actually done more work by creating
the generic types and then deriving our instances from them instead of creating
instances for OptionalString
and OptionalNonEmptyString
directly. In this
case it’s true, but as soon as we need a second, third, or fourth type that
would have the same boilerplate implementation of a type class, then we’ll have
saved ourselves some effort. Realistically, it’s not always clear when you’ll
want to reuse some definition of a typeclass, so you might find that instead of
creating the generic reusable types to start with, you recognize that you have
the same definition in multiple places and instead factor those out into a
common definition that you can use with deriving via
.