A supplement to Part 8: Functors and the Laws of Mapping in Haskell from First Principles.

Functor maps over one type parameter. That does not mean the original type constructor must have only one parameter. A constructor with several parameters can often support Functor after we fix the earlier parameters and leave the final one available for mapping.

This explains the instance Functor (Either e) from Part 8. We will derive the same idea for a custom type, then use Bifunctor to transform two parameters independently.

1. Match the kind that Functor expects

Recall the essential method of the existing Prelude class:

-- Simplified presentation; do not redeclare the Prelude class.
class Functor f where
  fmap :: (a -> b) -> f a -> f b

The parameter f must have kind:

f :: Type -> Type

It takes one type argument and produces an ordinary type. Here Type denotes the kind of ordinary lifted types, also written * in older notation. These kind descriptions explain the types; they are not value-level declarations to paste into a source file.

Consider a type with two parameters:

data T a b = MkT a b

Its constructor has kind:

T :: Type -> Type -> Type

Functor T is therefore ill-kinded: T still needs two arguments, while Functor expects a constructor with one remaining argument.

Applying T to its first type argument changes the situation:

Type expressionKindWhat remains
TType -> Type -> TypeTwo type arguments
T aType -> TypeOne type argument
T a bTypeA complete type

Thus an instance for Functor (T a) has the required kind. This is partial application at the type level: T a is the unary constructor we supply to Functor.

Matching the kind is a necessary first step. We still need to implement mapping and satisfy the identity and composition laws from Part 8. Having the right kind alone does not guarantee a lawful instance.

2. Preserve metadata and transform the value

Create a source file called FunctorParameters.hs. Start with these definitions; later sections add instances and types to the same file:

module FunctorParameters where
 
import Data.Bifunctor (Bifunctor (..))
 
data T a b = MkT a b
  deriving (Eq, Show)
 
instance Functor (T a) where
  fmap g (MkT metadata value) = MkT metadata (g value)

The import will be used when we add Bifunctor in the next section. Functor and fmap are already available from the Prelude.

Think of the first field as metadata and the second as a value. For example, MkT "score" 100 associates a score label with a number.

Substitute f = T a into the general method type. The resulting types are explanatory specializations:

General:
fmap :: (x -> y) -> f x -> f y
 
With f = T a:
fmap :: (x -> y) -> T a x -> T a y

The metadata type a is unchanged; the value type can change from x to y:

ComponentBefore mappingAfter mapping
Metadata typeaa
Value typexy
Complete typeT a xT a y

Load the file in GHCi, then evaluate:

ghci> :load FunctorParameters.hs
ghci> fmap (+1) (MkT "score" (100 :: Int))
MkT "score" 101
ghci> fmap show (MkT "score" (100 :: Int))
MkT "score" "100"

The second expression changes T String Int into T String String. Both the metadata type and its stored value stay unchanged in this implementation.

The a in instance Functor (T a) is a type variable, so this one instance supports every choice of metadata type. It does not permanently fix the metadata to String. Within an individual call to fmap, however, that choice stays the same on input and output.

For constructed values, the laws follow directly from the implementation:

fmap id (MkT metadata value)
  = MkT metadata (id value)
  = MkT metadata value
 
fmap (g . h) (MkT metadata value)
  = MkT metadata (g (h value))
  = fmap g (fmap h (MkT metadata value))

The fixed metadata travels through each step unchanged.

3. Map the first parameter, or both, with Bifunctor

For Functor (T a), the varying parameter is the last one. Changing the transformation passed to fmap does not make it target the first field instead.

Bifunctor provides a mapping operation for each of two type parameters. Its central method has this qualified type:

bimap :: Bifunctor p =>
         (a -> b) -> (c -> d) -> p a c -> p b d

The first function transforms the first parameter; the second transforms the second parameter. p itself has kind Type -> Type -> Type, so we use the full constructor T in the instance:

instance Bifunctor T where
  bimap g h (MkT metadata value) =
    MkT (g metadata) (h value)

Add this below the existing Functor (T a) instance, then reload the file:

ghci> :reload
ghci> bimap show (+1) (MkT (10 :: Int) (20 :: Int))
MkT "10" 21

The complete type changes from T Int Int to T String Int.

Defining bimap also gives us default implementations of first and second. Their qualified types are:

first  :: Bifunctor p => (a -> b) -> p a c -> p b c
second :: Bifunctor p => (b -> c) -> p a b -> p a c

They transform just one parameter:

ghci> first length (MkT "score" (100 :: Int))
MkT 5 100
ghci> second show (MkT "score" (100 :: Int))
MkT "score" "100"

These names come from the Data.Bifunctor import in our source file. Their relationship to bimap is:

first g  = bimap g id
second h = bimap id h

These are equations describing the methods, rather than new definitions to paste into the module.

In base 4.18 and later, Bifunctor p also requires Functor (p a) for every a, and second must agree with fmap. Our two instances meet that requirement. The full class uses a quantified superclass constraint; using the existing class as above needs no new language extension. See the official Data.Bifunctor documentation.

4. Apply the distinction to Either

The standard type has this structure:

-- Already provided by the Prelude; shown for explanation.
-- data Either e a = Left e | Right a

Functor (Either e) keeps the error type fixed and maps over the Right parameter. Bifunctor Either allows transformations of either parameter:

ghci> fmap (+1) (Right 100 :: Either String Int)
Right 101
ghci> fmap (+1) (Left "missing" :: Either String Int)
Left "missing"
ghci> first (++ "!") (Left "missing" :: Either String Int)
Left "missing!"

With bimap, we can provide both transformations at once:

ghci> bimap length show (Left "missing" :: Either String Int)
Left 7
ghci> bimap length show (Right 100 :: Either String Int)
Right "100"

Both results have type Either Int String. Each particular Either value contains a Left payload or a Right payload, so only the function for its constructor is applied. This differs from T, whose constructor contains both fields.

The choice of interface expresses what is allowed to vary:

InterfaceFixed parameterVarying parameter or parameters
Functor (Either e)Error type eSuccess type
Bifunctor EitherNeitherError type and success type

Neither standard instance needs to be redefined in our source file.

5. Generalize to three parameters

The same kind calculation works for a type with three parameters. Add:

data T3 a b c = T3 a b c
  deriving (Eq, Show)
 
instance Functor (T3 a b) where
  fmap g (T3 firstValue secondValue thirdValue) =
    T3 firstValue secondValue (g thirdValue)

The relevant kinds are:

T3     :: Type -> Type -> Type -> Type
T3 a b :: Type -> Type

Fixing the first two parameters leaves the final one available. The specialized mapping type is:

fmap :: (c -> d) -> T3 a b c -> T3 a b d

For example:

ghci> :reload
ghci> fmap show (T3 "score" True (100 :: Int))
T3 "score" True "100"

The label and Boolean flag stay unchanged; only the third field is transformed. This final-parameter behavior also explains the standard Functor instances for tuples with several fields, as described in the fmap documentation.

6. Keep the levels separate

The constructors and their instances can now be summarized:

Type constructorKindFunctor instance
MaybeType -> TypeFunctor Maybe
EitherType -> Type -> TypeFunctor (Either e)
TType -> Type -> TypeFunctor (T a)
T3Type -> Type -> Type -> TypeFunctor (T3 a b)

Three different levels are involved:

  • MkT "score" 100 is a value.
  • T String Int is a complete type of values.
  • T String is a type constructor waiting for one more type argument.

Functor applies at the third level. Its kind is:

It takes a unary type constructor such as Maybe or T String and forms a constraint requiring mapping operations for that constructor. Int and Maybe Int are complete types, so they cannot fill that role.

For these ordinary multi-parameter data types, partial application fixes the earlier parameters and lets fmap act on the final one. When two parameters support independent mapping, Bifunctor expresses that additional capability. In each case, the implementation must still obey the corresponding laws.

This distinction between a value, a complete type, and a partially applied type constructor carries into Part 9: Applicative Functors and Monads.

Adapted from the GPT-generated supplement supplied for Part 8, with complete imports and technical clarifications.

← Back to Part 8 · Series index · Continue to Part 9 →