Functor map

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, and b. For more details read wasp-lang/haskell-handbook/forall.md.
  • Functor f is a constraint. That means the f 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 and f a, and it returns a f 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 type f 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 and b.
  • 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 that Effect 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 Tasks 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/