This is an introduction to a programming idiom for placing extra constraints on the construction of values by using smart constructors.
Sometimes you need guarantees about the values in your program beyond what can be accomplished with the usual type system checks. Smart constructors can be used for this purpose.
Consider the following problem: we want to be able to specify a data type for electronic resistors. The resistors come in two forms, metal and ceramic. Resistors are labelled with a number of bands, from 4 to 8.
We'd like to be able to
- ensure only resistors with the right number of bands are constructed.
1 Runtime checking : smart constructors
1.1 A first attempt
Code up the a typical data type describing a resistor value:
data Resistor = Metal Bands | Ceramic Bands deriving Show
type Bands = Int
This has a problem however, that the constructors of type Resistor are unable to check that only bands of size 4 to 8 are built. It is quite legal to say:
*Main> :t Metal 23 Metal 23 :: Resistor
1.2 Smart(er) constructors
Smart constructors are just functions that build values of the required type, but perform some extra checks when the value is constructed, like so:
metalResistor :: Bands -> Resistor metalResistor n | n < 4 || n > 8 = error "Invalid number of resistor bands" | otherwise = Metal n
This function behaves like the constructor Metal, but also performs a check. This check will be carried out at runtime, once, when the value is built.
Running this code:
*Main> metalResistor 4 Metal 4
*Main> metalResistor 7 Metal 7 *Main> metalResistor 9 *** Exception: Invalid number of resistor bands
*Main> metalResistor 0 *** Exception: Invalid number of resistor bands
One extra step has to be made though, to make the interface safe. When exporting the type Resistor we need to hide the (unsafe) constructors, and only export the smart constructors, otherwise a reckless user could bypass the smart constructor:
module Resistor ( Resistor, -- abstract, hiding constructors metalResistor, -- only way to build a metal resistor ) where
1.3 Using assertions
Hand-coding error messages can be tedious when used often. Instead we can use the assert function, provided (at least with GHC). We rewrite the smart constructor as:
metalResistor :: Bands -> Resistor metalResistor n = assert (n >= 4 && n <= 8) $ Metal n
And now obtain more detailed error messages, automatically generated for us:
*Main> metalResistor 0 *** Exception: A.hs:4:18-23: Assertion failed
We at least now are given the line and column in which the error occured.
2 Compile-time checking : the type system
2.1 Enforcing the constraint statically
There are other ways to obtain numerical checks like this. The most interesting are probably the static checks that can be done with Type arithmetic, that enforce the number of bands at compile time, rather than runtime, by lifting the band count into the type level.
Further checks can be obtained by separating the metal and ceramic values on the type level, so no function that takes a metal resistor can be accidentally passed a ceramic one.
A newtype is useful for this:
newtype MetalResistor = Metal Bands newtype CeramicResistor = Ceramic Bands
now, a function of resistors must have either a MetalResistor type, or a CeramicResistor type:
foo :: MetalResistor -> Int foo (MetalResistor n) = n
You can't write a function over both resistor types (other than a purely polymorphic function).
2.3 Related work
In general, the more information you place on the type level, the more static checks you get -- and thus less chance for bugs.