This post explores the map function in Purescript. Start a REPL by running these commands in a new directory:
mkdir test-functor; cd test-functor
spago init
spago install maybe arrays node-process
spago repl
Context
Let’s consider this data type to represent a task:
> data Task = Task { name :: String, mem :: Int, cpu :: Int }
And this function to create a new task:
> mkTask name = Task { name, mem: 0, cpu: 0 }
> :t mkTask
String -> Task
(And copy paste this show instance to see the result in the REPL):
instance showTask :: Show Task where show (Task t) = "(Task " <> t.name <> ")"
We can create a task:
> task = mkTask "worker"
> task
(Task worker)
> :t task
Task
So far so good, nothing special to see here.
Mapping mkTask
Here is the definition of map mkTask
:
> :t mkTask
String -> Task
> :t map mkTask
Functor f => f String -> f Task
map mkTask
is a function that takes a structure (called functor) holding a String, and it returns a new structure holding a Task. In otherwords, map mkTask
inject the mkTask
function inside the functor. In other otherwords, map
embellished mkTask
to work with functors.
Here are some example usages, using the Maybe Functor (don’t forget to import Data.Maybe
):
> map mkTask Nothing
Nothing
> map mkTask (Just "worker")
(Just (Task worker))
Or with the Array Functor:
> map mkTask ["worker1", "worker2"]
[(Task worker1), (Task worker2)]
Thanks to the Functor abstraction, we are able to modify the value contained in different structure using a common map function.
Map definition
map is defined as follow:
> :t map
forall f a b. Functor f => (a -> b) -> f a -> f b
There are three parts (separated by .
and =>
):
-
forall f a b
is the quantification. That means there will be three type variables used in the definition:f
,a
, andb
. For more details read wasp-lang/haskell-handbook/forall.md. -
Functor f
is a constraint. That means thef
type variable needs to be a Functor. For more details read pursuit Functor. -
(a -> b) -> f a -> f b
is the function signature. That means this function expects two arguments,a -> b
andf a
, and it returns af b
.
Here f
is a type constructor, in the signature it is given a type. That means f
expects a type argument to become a final type. For more details read purescript-book/chapter3, or watch this An introduction to Haskell’s kinds video by Richard A. Eisenberg.
Now Let’s see why this works.
Map type variables
The map definition is polymorphic, that means it can work in many scenarios depending on its arguments. We can observe how the type checker works by providing the argument one by one:
> :t map
forall f a b. Functor f => (a -> b) -> f a -> f b
> :t map mkTask
forall f. Functor f => f String -> f Task
> :t map mkTask []
Array Task
Notice how when using mkTask
the type variable a
becomes a String, and the b
becomes a Task. This is because these types are no longer variable after we use mkTask
: the polymorphic argument a -> b
becomes String -> Task
, and the other variable name occurences are replaced accordingly.
We can also change the order of the argument to provide the functor before the function using flip
:
> :t flip map
forall f a b. Functor f => f a -> (a -> b) -> f b
> :t flip map []
forall a b. (a -> b) -> Array b
> :t flip map ["x"]
forall b. (String -> b) -> Array b
> :t flip map ["x"] mkTask
Array Task
Notice how the type variable f
becomes an Array, and the a
becomes a String. This is because ["x"]
is a Array String
when the function expect a f a
, thus the other variables are replaced accordingly:
-
When
Array String
is used for an argument of typef a
, then -
(a -> b) -> f b
, becomes:(String -> b) -> Array b
.
This process can be refered to as specialization, and it is helpful to understand function signature by removing type variables.
Motivating example
Finally, here is a last example to demonstrate map with lookupEnv
.
> import Node.Process
> :t lookupEnv
String -> Effect (Maybe String)
lookupEnv
expects a name, and it returns an Effect
containing an optional value.
> lookupEnv "USER"
(Just "tdecacqu")
Note that the REPL automatically perform the
Effect
.
We already saw how we can change the value of a Maybe using map, but lookupEnv
returns an extra Effect layer. Let’s consider this double map usage:
> :t map (map mkTask)
forall f g. Functor f => Functor g => f (g String) -> f (g Task)
We can use it to penetrate both the Effect and the Maybe functor to modify the final value in one shot while preserving the structure:
> :t map (map mkTask) (lookupEnv "USER")
Effect (Maybe Task)
> map (map mkTask) (lookupEnv "USER")
(Just (Task tdecacqu))
Map is so powerful it has an operator version: <$>
which let us rewrite this code as:
> map mkTask <$> lookupEnv "USER"
Which means, given the Effect (Maybe String)
returned by lookupEnv, we’ll inject map mkTask
into the Effect
, to convert the optional value into an optional Task.
And we can use this for any two functors combinaison, for example, a list of optional string:
> xs = [Nothing, Just "worker1", Just "worker2"] :: Array (Maybe String)
> map mkTask <$> xs
[Nothing, (Just (Task worker1)), (Just (Task worker2))]
And this concludes the exploration. Thanks for your time!
Bonus: traverse
Well while you are here, here is traverse
:
> import Data.Traversable
> :t traverse
forall t m a b. Traversable t => Applicative m => (a -> m b) -> t a -> m (t b)
Nothing special here, we know how to read this. To recap, this definition means:
-
There are 4 type variables:
t
,m
,a
andb
. -
t
is a Traversable, which is a foldable functor (and most Functor are Traversable), see this for more details: pursuit Traversable. -
m
is an Applicative, and let’s not bother with what that means exactly, but just know thatEffect
is an applicative: see its instance list: pursuit Effect.
Thus, given a function a -> m b
, and a traversable t a
, traverse will produce a m (t b)
.
For example we can use traverse with lookupEnv because lookupEnv is compatible with a -> m b
, it is String -> Effect (Maybe String)
:
> :t traverse lookupEnv
forall t. Traversable t => t String -> Effect (t (Maybe String))
Notice how the lookupEnv definition sets the type variable a
to String, m
to Effect and b
to (Maybe String), leaving us with the last type variable t
.
This definition means that given a collection of string, traverse lookupEnv
will perform each individual lookup and return the result wrapped in a single Effect:
> :t traverse lookupEnv ["USER", "HOSTNAME"]
Effect (Array (Maybe String))
> traverse lookupEnv ["USER", "HOSTNAME"]
[(Just "tdecacqu"), (Just "localhost")]
This result uses 3 Functors: Effect, Array and Maybe. And of course we can use maps to penetrate all the layers:
> :t map (map mkTask) <$> traverse lookupEnv ["USER", "HOSTNAME"]
Effect (Array (Maybe Task))
> map (map mkTask) <$> traverse lookupEnv ["USER", "HOSTNAME", "OOPS"]
[(Just (Task tdecacqu)), (Just (Task localhost)), Nothing]
Or using function composition:
> traverse (map (map mkTask) <<< lookupEnv) ["USER", "HOSTNAME"]
[(Just (Task tdecacqu)), (Just (Task localhost))]
Which is really convenient as we don’t have to unwrap anything. Using map we modify lookupEnv
to convert a traversable structure of String
into Task
s by reading their value from the environment:
> :t traverse (map (map mkTask) <<< lookupEnv)
forall t. Traversable t => t String -> Effect (t (Maybe Task))
Cheers o/