tl;dr - Haskell’s worth checking out if you haven’t yet, you’ll find some features you may want to bring back to your beloved $LANGUAGE.
Rah-Rah Haskell posts in 2021? You betcha.
Note that all examples are written in simple Haskell where possible. No eta reductions for example, the code should be very easy to understand. Most of the code in here will sail past the compiler (you could use it for your own projects!) but some if it might not, but is very close to code that would compile.
Haskell’s syntax takes some getting used to like most other languages, but it is beautiful. It encourages writing small composable functions that look like this:
someFunction :: String -> String
someFunction s = s ++ " foo"
As you might guess someFunction
is the name of the function, String
is the type of the first argument the function takes, and String
denotes the type of the output. I’ve chosen to call the first argument s
in the example, and ++
denotes string concatenation though it’s not too important here. A function with two inputs looks like this:
anotherFunction :: String -> String -> String
anotherFunction first second = first ++ " foo " ++ second
You can call the arguments whatever you want (first
, f
, foo
) – it’s not the naming that matter, it’s the types that matter. This is a boon to code readability and encourages people to write small well-defined functions that do one thing well.
Another thing that haskell is really fantastic at is composition of functions. Let’s combine two functions that operate similarly:
addFoo :: String -> String
addFoo s = "foo " ++ s
addBar :: String -> String
addBar s = s ++ " bar"
addFooAndBar :: String -> String
addFooAndBar = addFoo . addBar
So what’s happening there? Well we’ve made a new function addFooAndBar
that is the composition of .
function composition operator. addBar
will be called first, then the resulting value will be passed directly to addFoo
so that can be run as well. A String
goes in one end, gets passed through addBar
and comes out, immediately that string is fed to addFoo
and it comes ouf of there – the composition of these functions produces a string as well so the type signature of the composed function addFooAndBar
is the very reasonable String -> String
.
When you execute addFooAndBar
, it’ll look just like any other function call in Haskell:
addFooAndBar "string" -- = "foo string bar"
Most other languages will look something like this for composition:
add_foo(add_bar(value))
That’s not bad, but gets a bit tedious – imagine a composition 3-5 functions long?
Friends don’t let friends use nullable types when they have a choice. If you’ve ever used Optional<T>
in Java and reached the realization that every single object you’ve been dealing with your whole Java life is secretly an Optional<T>
, then you realize the usefulness of non-nullable types.
An example:
-- Int will never be null, and you don't have to worry about it ever being null
plusOne :: Int -> Int
plusOne n = n + 1
-- If you *want* to work on values that may or may not be there
plusOne :: Maybe Int -> Maybe Int
plusOne (Just n) = Just (n + 1)
plusOne Nothing = Nothing
Being forced to be always be strict about the differences here is worth applauding in a language.
Not every error is exception
al. Somewhat similar to the non-nullable types quality-of-life improvement, when things can fail in an unexceptional manner, you can properly represent that in the same thread of computation as everything else. For example a web request failure should not be unexpected to developers familiar with the fallacies of distributed computing). Haskell has a nice useful Either
type:
An example:
-- Either is in the standard library (Prelude) but
-- if we were to define it ourselves:
-- data Either err value = Left err
-- | Right value
--
-- Process a response to extract a value, *if* it worked
processReponse :: Either SomeError Response -> Maybe String
processReponse (Left err) = Nothing
processReponse (Right resp) = doSomeProcessing resp
Haskell will force you to handle both cases – where something has gone wrong, and when it’s all gone right.
Go ahead, make a change that absolutely breaks everything in your codebase – in Haskell that’s a great way to refactor, because the compiler won’t let you get away with it, 99% of the time! That 1% of the time is usually when you’ve enabled an extension, doing some useful-but-unstable type magic, or dealing with fundamentally incorrect business/logic requirements of one form or another.
It’s hard to give a good example for this so I’ll just let you think back on any really bad refactoring experiences (particularly in dynamic languages) you have an imagine a better one – one where you could write the version of the function that’s right, then work your way back correcting the types to make sure everything slots into place, easily.
I have to say, I don’t enjoy writing unit tests. They’re unquestionably useful (I’d be an idiot not to) but I find I can get much farther not writing them in strongly typed languages. Being excited that you wrote a test to check if -1
got submitted to a function that expects only natural numbers is a bit silly to me these days.
Not that defensive coding and trying to think like a “red team”/how your function can break is useless, but if a certain state should be impossible, make it impossible at the type system level!
-- This function works on natural numbers, which are guaranteed to be
-- 0 or greater!
addOneToNat :: Natural -> Natural
addOneToNat n = plusNatural 1 n
But wait you might ask – what if you make a minusOneOnNat
, what protects you from minusing one from zero? Well we’ve got two options with GHC.Natural
minusNatural :: Natural -> Natural -> Natural
minusNaturalMaybe :: Natural -> Natural -> Maybe Natural
How could the first one possibly protect us? Well the answer is it doesn’t, it throw
s. This is the dirty anything-can-explode world that the other lesser languages live in, so obviously avoid using that one at all costs! Use the minusNaturalMaybe
and now you can protect your operations (and surface the fact that you’ve done something potentially unsafe so other downstream functions can react).
Yeah a bunch of other languages are catching on, but Haskell is already there (and likely inspired the other languages or designers). It’s all over the language:
-- Nice and obvious, get a one, spit out a True
isAOne :: Int -> Bool
isAOne 1 = True
isAOne _ = False
-- What if you didn't know you had an int or not?
IsAOneMaybe :: Maybe Int -> Bool
IsAOneMaybe (Just 1) = True
IsAOneMaybe (Just _) = False
IsAOneMaybe Nothing = False
OK, now we’re going to get just a little type-y, but don’t worry, it’s pretty simple. Try and write something like this in your favorite untyped/typed $LANGUAGE:
-- An alias (called "newtype") in Haskell
-- to functions, UserID and String are the same thing
-- now you can write functions like (getUserID :: User -> UserID)
type UserID = String
-- A "newtype" container type, with a constructor function to do validation as you like
-- newtypes act as the original type but can be used to imbue value/add constraints (see: https://wiki.haskell.org/Newtype)
newtype UserID2 = UserID2 { _inner :: String }
-- A constructor function (if you share only this and not the UserID2 value constructor, you've cot control over how people can make the value)
mkUserID :: String -> Maybe UserID2
mkUserID s = case trimmedString of
"" -> Nothing
"not-this" -> Nothing
s -> Just (UserID2 s)
Look how nice and concise that is!
Speaking of IDs, have you ever run into that really weird situation whenf you have multiple things that are basically the same thing, but made different ways and you want them to be the same type but your language won’t let you? Generalized Algebraic Data Types (BEWARE that link gets intense VERY quickly) sound scary but they actually solve this problem with relative ease. Let’s use a problem that’s more common than it should be to illustrate:
-- You can create a CrossServiceID
data CrossServiceID k where
UUIDID :: UUID -> CrossServiceID UUID
StringID :: String -> CrossServiceID String
IntID :: Int -> CrossServiceID Int
Unfortunately CrossServiceID
won’t save you from having to deal the architecture you’ve created but at the very least it will force you to find out the “right” every time you work with a CrossServiceID
, while giving you a nice combined type to use. Said a different way – at runtime when you receive a CrossServiceID
, you can use haskell’s destructuring to figure out which kind of ID you have, while the rest of the world happily in CrossServiceID
s not knowing the difference.
The secret is out, you probably don’t want to mix structure and functionality in your Types/Classes. Go got it right with their method implementation and their interfaces, Rust has it right with Traits and before both of those Haskell had it right with Typeclasses. Your type and what it can do should be separate:
-- | Some class
data SomeType = SomeType { name :: String
, counter :: Int
}
-- | A (type)class that deals with things which have counters
class Counter a where
increment :: a -> Int -> a
decrement :: a -> Int -> a
-- | An typeclass instance that implements Counter for SomeType
instance Counter SomeType where
increment :: SomeType -> Int -> SomeType
increment t@SomeType{counter} n = t{counter=counter + 1}
decrement :: SomeType -> Int -> SomeType
decrement t@SomeType{counter} n = t{counter=counter + 1}
decrement t@SomeType{counter} n = t{counter=counter - 1}
Nice and separate! And when you want to layer typeclasses, no need for dirty-no-good class inheritance – use Haskell’s Constraint system:
-- | Typeclass for a Database Backend
class (HasLogger b, MonadIO m) => DatabaseBackend b m where
-- | Connect the backend
connect :: b -> m b
-- | Disconnect the backend
disconnect :: b -> m b
-- | Get the current backend version
getCurrentVersion :: b -> m BackendVersion
-- | Migrate to a given backend version
migrateToVersion :: BackendVersion -> b -> m (Maybe b)
-- | Entity stores must be capable of performing various functions on entities
class DatabaseBackend b m => EntityStore b m where
-- | Create some entity that has been deemed Insertable
create :: InsertableDBEntity entity => b -> entity -> m (Either DBError (ModelWithID e))
-- .... elided ...
class EntityStore b m => UserStore b m where
-- | Add a new user
addUser :: b -> User -> Password -> m (Either DBError (ModelWithID User))
-- ... elided ...
The real code is a bit more complicated but in general this code tells us:
addUser
we want to be sure that we have a some kind of UserStore b m
b
is a polymorphic variable that represents the database,m
is the flavor of monad world we’re operating inEntityStore
, which lets us know we can create
, and of course to create
we must be able to connect
to the databse, etc. Thecreate
we must be dealing with some sort of EntityStore b m
b
and m
are as beforeDatabaseBackend b m
connect
and the other functions we msut be dealing with some sort of DatabsaeBackend b m
b
is needs to satisfy HasLogger
m
is needs to have MonadIO
Downstream, people only need to think in terms of UserStore
s that they’ve been given and they can be assured the rest of the Constraint
s have been met.
A few more breaths of fresh air in this space:
Haskell has a fantastic Runtime System (RTS) which has great support for the following:
pthread.h
)Other languages often have the first (System threads) by way of underlying OS interoperability (pthread.h
), and sometimes have the second multiplexed on top (ex. Golang’s Goroutines), but they rarely have the third – extensive constructs to robustly interact with shared memory. While Haskell doesn’t focus on completely preventing unsafe things like memory races in the same way Rust does, using STM (even naively) generally keeps you very safe.
Unfortunately there are a few things that can go wrong in Haskell here:
Haskell isn’t perfect here but outside of these issues it’s pretty great ride. You’re likely going to have other problems before you hit these.
A lot of ink has been spilled over Monads – what they are, how to use them, etc. I won’t do more than repeat the usual “Monads are monoids in the category of endofunctors”. If I were to put that in somewhat more useful terms, Monads are a way to contain (usually effectful) computations and the state that goes with them. That’s not all you can use Monads for but it’s not a terrible way to think about them. Let’s not get into it too deeply, because what I want to show you is a somewhat advanced feature anyway, selective capability exposure with monads (“classy mtl
” for those in the know):
findAddressByID :: (HasDBBackend m db, MonadError ServerError m) => AddressID -> m (EnvelopedResponse (ModelWithID Address))
findAddressByID cid = getDBBackend
>>= liftIO . flip getAddressByID cid
>>= ifNothingThrowIOError Err.failedToRetrieveResource
>>= pure . EnvelopedResponse "success" "Successfully retrieved address"
Without thinking too deeply about the code or what it means, this code takes a m
(Monad) that adheres to the HasDBBackend
type class, and the MonadError ServerError
typeclass. As you might imagine, that all we know about the m
is that it “has” a DB backend, and can throw errors!. It’s not absolutely impossible to perform unknown behavior (I’ll spare you the how), but it’s drastically less likely now, and we have a function that takes an AddressID
(whatever that is) and returns a Monad that when executed will return a EnvelopedResponse (ModelWithID Address)
. It’s a bit of a mouthful but the important part here is that
Another pattern rapidly gaining steam in Haskell communities is Free/FreeR Monads and Effect systems (libraries like freer-simple
, polysemy
and others) which takes this approach even further. You can define an “effect model”:
-- | The type of an key used to restrict an object
-- You might want to create a constructor that enforces some invariants
newtype ObjectKey = ObjectKey { _key :: String }
-- | The Effect Model of an object store
data ObjectStore s where
GetObject :: ObjectKey -> ObjectStore ()
PutObject :: ObjectKey -> ByteString -> ObjectStore ByteString
DeleteObject :: ObjectKey -> ObjectStore ()
getObject' :: Member ObjectStore effs => Eff effs String
getObject' = send GetObject
putObject' :: Member ObjectStore effs => Eff effs String
putObject' = send PutObject
deleteObject' :: Member ObjectStore effs => Eff effs String
deleteObject' = send DeleteObject
Well awesome, but you need to actually write the implementation right? Well here’s the cool part – you can write the interpreters (which contain the implementation) any way you want:
runObjectStoreS3 :: Eff '[ObjectStore, IO] a -> IO a
runObjectStoreS3 = runM . interpretM (\case
PutObject key bytes -> saveToS3 region bucket accessKey secretKey key bytes
GetObject key -> retrieveFromS3 region bucket accessKey secretKey key
DeleteObject key -> deleteFromS3 region bucket accessKey secretKey key
)
Obviously the key saveToS3
function and retrieveFromS3
functions there are black boxes, and only having the key
be variable is a bit silly, but you get the idea. The bucket can easily be specified on in the Effect model just like the Key
is, or you could choose to switch out whole interpreters to accomplish this, which might make calling code simpler at the expensive of the setup code (maybe for different environments).
And when you want to run this effect in testing:
runObjectStoreHashMap :: Eff '[ObjectStore, IO] a -> IO a
runObjectStoreHashMap = runM . interpretM (\case
PutObject key bytes -> insert key bytes yourMap
GetObject key -> retrieveFromS3 region bucket accessKey secretKey key
DeleteObject key -> deleteFromS3 region bucket accessKey secretKey key
)
And now you have a guaranteed hot-swappable implementation which will never require any upstream code changes. Your upstream code is free to live at the level of the ObjectStore
effect model, without worrying about what’s underneath.
There’s a bit of a learning curve to both Monads and Effect Systems, but they’re incredibly clean and powerful concepts for use in creating and managing abstractions.
Rust is basically the closest non-ML langauge to Haskell (AFAIK), they’ve taken so many of the good points that maybe you should just use that. It’s got some different pitfalls and a steep learning curve (essentially trade monads for manual memory management + borrowing), but it is useful at two different scopes where haskell is not – low level development (places where large runtimes don’t go – micro controllers, etc), and the web (WASM).
There also seems to be quite a few Haskellers kicking the tires on Rust (and a lot who are just plain into it at this point, since it’s been a while).
For those who love semantics, check out the under-construction (AFAIK) Haskell in plain English (Google Doc).
Hopefully this article was a litle provocative and challenges you to look at the parts of your language that could be a little more Haskell-like (the good parts and not the bad of course!). Popularly used languages have come a long way and there’s more to go – copying is encouraged.