Skip to content

Product Types

The reason why we call data constructors data constructors is because they can take arguments. For example, we can define a point in two-dimensional space as

data Point2D = P Double Double

or

data Point2D = Point2D Double Double

In this case, the data constructor is P (in the first definition) or Point2D (in the second definition). This demonstrates that the data constructor(s) of a type can have the same name as the type or a different name. I'll stick to the first definition for now because different names for the type—Point2D—and the data constructor—P—avoid confusion whether I talk about the type or data constructor.

To construct a Point2D, we need two Double values:

GHCi
>>> data Point2D = P Double Double
>>> p = P 10.3 20

So Point2D is really nothing but a pair of Doubles. However, as far as the compiler is concerned, Point2D and (Double, Double) are two different types. That's exactly the point: By defining a custom type for 2-d points, we make our intent clear what this pair of Doubles represents—a point—and the compiler can check that we never pass just any pair of Doubles to a function meant to manipulate points.

So why do we call things like P, Red, Green, Blue data constructors? If you squint at it right, you will see that P is nothing but a function. It takes two arguments of type Double and returns a Point2D. Indeed, this is exactly how the Haskell compiler sees it:

GHCi
>>> :type P
P :: Double -> Double -> Point2D

Naming a function that constructs a Point2D from its parts a data constructor is an apt choice. We can similarly view Red, Green, and Blue to be functions that construct values of type Color, only they don't take any arguments, but that's fine.

Our function colorAsString above showed us that we can use data constructors as patterns when using pattern matching. The same is true for data constructors that take arguments, but now we need nested patterns to match the arguments of the data constructor. Here are two examples that make this clearer:

  • P x y is a pattern that matches any value of type Point2D, because any such value is constructed using the data constructor P. The inner patterns used to match the two Double values passed to P are variables here, x and y. Since variables match any value, this pattern imposes no constraints on the two double values. Also remember that using a variable as a pattern means that the value matched to the variable as part of pattern matching gets assigned to the variable. So, in this pattern, x can be used to refer to the first component of the point, its \(x\)-coordinate presumably, and y can be used to refer to the second component, its \(y\)-coordinate. We can use this to implement functions to access the two coordinates of a point:

    xCoord, yCoord :: Point2D -> Double
    xCoord (P x _) = x
    yCoord (P _ y) = y
    

    or to convert between a point and a plain old pair:

    pointAsPair :: Point2D -> (Double, Double)
    pointAsPair (P x y) = (x, y)
    
    pairAsPoint :: (Double, Double) -> Point2D
    pairAsPoint = uncurry P
    

    Sneaky old me: I'm making sure that you don't forget our good old uncurry function used to convert a function that takes two arguments into a function that takes a pair as its only argument. Since P has type Double -> Double -> Point2D, uncurry P has type (Double, Double) -> Point2D: it converts a pair of Doubles into a Point2D.

    There's a better way to define a point type along with accessors to its coordinates. We'll talk about it soon.

  • Just as we can use patterns that match only specific values,

    isZero :: Int -> Bool
    isZero 0 = True
    isZero _ = False
    

    we can use such values as patterns nested inside a data constructor. For example,

    isOnYAxis :: Point2D -> Bool
    isOnYAxis (P 0 _) = True
    isOnYAxis _       = False
    

    The first pattern, P 0 _ matches only points whose \(x\)-coordinate is 0. The wildcard for the \(y\)-coordinate says that we don't care what the \(y\)-coordinate is. Thus, this pattern matches all points with \(x\)-coordinate 0, which are the points on the \(y\)-axis of the coordinate system. If the first pattern does not match, then we know that the \(x\)-coordinate is not 0. In that case, we know that the point is not on the \(y\)-axis, so we don't need to know anything else about the point to answer False: the point is not on the \(y\)-axis. This justifies that we use a wildcard for the whole point in the second equation.

    Note that in this instance, the pattern P _ _ would have achieved the same, as this pattern would also match every single point. As we discuss next, this isn't the case for all data types.