Let's say I have an internal data type, T a
, that is used in the signature of exported functions:
module A (f, g) where
newtype T a = MkT { unT :: (Int, a) }
deriving (Functor, Show, Read) -- for internal use
f :: a -> IO (T a)
f a = fmap (\i -> T (i, a)) randomIO
g :: T a -> a
g = snd . unT
What is the effect of not exporting the type constructor T
? Does it prevent consumers from meddling with values of type T a
? In other words, is there a difference between the export list (f, g)
and (f, g, T())
here?
The first thing a consumer will see is that the type doesn't appear in Haddock documentation. In the documentation for f
and g
, the type T
will not be hyperlinked like an exported type. This may prevent a casual reader from discovering T
's class instances.
More importantly, a consumer cannot doing anything with T
at the type level. Anything that requires writing a type will be impossible. For instance, a consumer cannot write new class instances involving T
, or include T
in a type family. (I don't think there's a way around this...)
At the value level, however, the main limitation is that a consumer cannot write a type annotation including T
:
> :t (f . read) :: Read b => String -> IO (A.T b)
<interactive>:1:39: Not in scope: type constructor or class `A.T'
The restriction on type signatures is not as significant a limitation as it appears. The compiler can still infer such a type:
> :t f . read
f . read :: Read b => String -> IO (A.T b)
Any value expression within the inferrable subset of Haskell may therefore be expressed regardless of the availability of the type constructor T
. If, like me, you're addicted to ScopedTypeVariables
and extensive annotations, you may be a little surprised by the definition of unT'
below.
Furthermore, because typeclass instances have global scope, a consumer can use any available class functions without additional limitation. Depending on the classes involved, this may allow significant manipulation of values of the unexposed type. With classes like Functor
, a consumer can also freely manipulate type parameters, because there's an available function of type T a -> T b
.
In the example of T
, deriving Show
of course exposes the "internal" Int
, and gives a consumer enough information to hackishly implement unT
:
-- :: (Show a, Read a) => T a -> (Int, a)
unT' = (read . strip . show') `asTypeOf` (mkPair . g)
where
strip = reverse . drop 1 . reverse . drop 9
-- :: T a -> String
show' = show `asTypeOf` (mkString . g)
mkPair :: t -> (Int, t)
mkPair = undefined
mkString :: t -> String
mkString = undefined
> :t unT'
unT' :: (Show b, Read b) => A.T b -> (Int, b)
> x <- f "x"
> unT' x
(-29353, "x")
Implementing mkT'
with the Read
instance is left as an exercise.
Deriving something like Generic
will completely explode any idea of containment, but you'd probably expect that.
In the corners of Haskell where type signatures are necessary or where asTypeOf
-style tricks don't work, I guess not exporting the type constructor could actually prevent a consumer from doing something they could with the export list (f, g, T())
.
Export all type constructors that are used in the type of any value you export. Here, go ahead and include T()
in your export list. Leaving it out doesn't accomplish anything other than muddying the documentation. If you want to expose an purely abstract immutable type, use a newtype
with a hidden constructor and no class instances.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With