How to add a type annotation of an intermediate value when combining functions of a type class?

Viewed 96

Background: I am working on a declarative compiler. In this course, I am going to write a class to construct an intermediate data structure. After building the data structure, the output can be redendered from the data structure. To simplify for stackoverflow, I created the following code:

module Main where

import qualified Data.Word as W
import qualified Octetable as Oct

main :: IO ()
main = 
    do
        print (buildNRender "123")

data MyData = MyData Integer

data Construction model = Contains model | Error String
    deriving Show

class Builder g where
    build :: String -> (Construction g)
    render :: (Construction g) -> [W.Word8]
    buildNRender :: String -> [W.Word8]
    buildNRender = render . build

instance Builder MyData where
    build s = Contains (MyData (read s :: Integer))
    render (Contains (MyData n)) = Oct.toOctets n
    render (Error _) = []

The obvious problem is, 'buildNRender' cannot be part of Builder, because type parameter g is not used at all.

Now, it seems obvious to me that a type class cannot work like this, where an intermediate value in a combination of two or more functions has a type parameter.

The following code makes the intermediate type explicit, and works - but without buildNRender.

...

main :: IO ()
main = 
    do
        print (render ((build "123") :: (Construction MyData))

...

However, is there an elegant way to define such a DEFAULT method of a class (like 'buildNRender'), and to specify the intermediate type in the context of the caller, like in the following code?

...

main :: IO ()
main = 
    do
        print ((buildNRender "123") :: ?(Construction MyData)?)

...
2 Answers

The obvious problem is, buildNRender cannot be part of Builder, because type parameter g is not used at all.

Well, that used to be a problem (concretely, the g would be ambiguous), but isn't anymore because GHC now has extensions that allow working with such parameters.

{-# LANGUAGE AllowAmbiguousTypes, TypeApplications, ScopedTypeVariables, UnicodeSyntax #-}
    
module Main where

import qualified Data.Word as W
import qualified Octetable as Oct

main :: IO ()
main = 
    do
        print (buildNRender @MyData "123")

data MyData = MyData Integer

data Construction model = Contains model | Error String
    deriving Show

class Builder g where
    build :: String -> (Construction g)
    render :: (Construction g) -> [W.Word8]

buildNRender :: ∀ g . Builder g => String -> [W.Word8]
-- ∀ (forall) g . introduces the type variable g into scope
-- needs extension AllowAmbiguousTypes
buildNRender = render . build @g -- @g is a Type Application

instance Builder MyData where
    build s = Contains (MyData (read s :: Integer))
    render (Contains (MyData n)) = Oct.toOctets n
    render (Error _) = []

Alternatively, without UnicodeSyntax:

{-# LANGUAGE AllowAmbiguousTypes, TypeApplications, ScopedTypeVariables #-}

...

buildNRender :: forall g . Builder g => String -> [W.Word8]

AllowAmbiguousTypes and TypeApplications are definitely a good approach these days. If, however, you prefer to avoid them, you can use one of two classic techniques: proxy passing or newtype tags.

Proxy passing

It's easiest with ScopedTypeVariables:

class Builder g where
    build :: String -> Construction g
    render :: Construction g -> [W.Word8]
    buildNRender :: proxy g -> String -> [W.Word8]
    buildNRender _ = render . (build :: String -> Construction g)

The g type variable in the class header will scope over the body of the buildNRender default definition, and can be used within to resolve the ambiguity. For example,

buildNRender (Proxy :: Proxy MyData) "123"

The proxy argument passed to buildNRender can be any type whose last type argument represents the type you want. The canonical choice is defined in Data.Proxy:

data Proxy a = Proxy

If you want to avoid ScopedTypeVariables too, then you'll need some sort of helper function. For example, you could write

blub :: proxy g -> (String -> Construction g) -> String -> Construction g
blub _ = id

and then

  buildNRender p = render . blub p build

If you need a lot of this sort of thing, you can define more general versions. blub's type, for example, could be rewritten with prefix notation:

blub :: proxy g -> (->) String (Construction g) -> (->) String (Construction g)
blub _ = id

This leads to a generalization,

blub :: proxy g -> f (c g) -> f (c g)
blub _ = id

which can be used in exactly the same way.

Newtype tagging

To avoid any possible runtime effects of proxy passing at the cost of substantial annoyance in handling, you can import Data.Tagged, which defines

newtype Tagged s b = Tagged {unTagged :: b}
-- It has a Functor instance

Now you can write (with ScopedTypeVariables)

  buildNRender :: Tagged g (String -> [W.Word8])
  buildNRender = Tagged (render . (build :: String -> Construction g))

Without ScopedTypeVariables, things are again trickier. One option would be

blurble :: f (c g) -> Tagged g (f (c g))
blurble = Tagged

  buildNRender :: Tagged g (String -> [W.Word8])
  buildNRender = (render .) <$> blurble build

Call could be:

unTagged (buildNRender :: Tg.Tagged MyData (String -> [W.Word8])) "123"
Related