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 bThe parameter f must have kind:
f :: Type -> TypeIt 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 bIts constructor has kind:
T :: Type -> Type -> TypeFunctor 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 expression | Kind | What remains |
|---|---|---|
T | Type -> Type -> Type | Two type arguments |
T a | Type -> Type | One type argument |
T a b | Type | A 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 yThe metadata type a is unchanged; the value type can change from x to y:
| Component | Before mapping | After mapping |
|---|---|---|
| Metadata type | a | a |
| Value type | x | y |
| Complete type | T a x | T 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 dThe 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" 21The 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 cThey 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 hThese 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 aFunctor (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:
| Interface | Fixed parameter | Varying parameter or parameters |
|---|---|---|
Functor (Either e) | Error type e | Success type |
Bifunctor Either | Neither | Error 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 -> TypeFixing the first two parameters leaves the final one available. The specialized mapping type is:
fmap :: (c -> d) -> T3 a b c -> T3 a b dFor 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 constructor | Kind | Functor instance |
|---|---|---|
Maybe | Type -> Type | Functor Maybe |
Either | Type -> Type -> Type | Functor (Either e) |
T | Type -> Type -> Type | Functor (T a) |
T3 | Type -> Type -> Type -> Type | Functor (T3 a b) |
Three different levels are involved:
MkT "score" 100is a value.T String Intis a complete type of values.T Stringis 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.