Haskell mini-idiom: constraining coerce

I really like using coerce. It allows you to skip tedious manual wrapping and unwrapping of newtypes. The instance for functions is especially useful, avoiding you the drudgery of going through all the positional parameters before reaching the result that you want to change.

Yet sometimes coerce is a bit too powerful. When using it with complex types, I start to second-guess myself, hoping that I’m not mistakenly plugging some value which happens to fit because some newtype I hadn’t considered is unexpectedly unwrapped.

For example, recently I had the need to work with deeply nested compositions of functors. Something like:

import Data.Functor.Compose
type Phases = IO `Compose` IO `Compose` IO `Compose` IO

defining values of this type is very tedious because of the Compose spam:

value :: Phases Int
value = Compose (pure (Compose (pure (Compose (pure (pure 5))))))
-- in the real case, each applicative layer performs actual effects

It’s simpler to define a newtype-less value, and then coerce it:

bareValue :: IO (IO (IO (IO Int)))
bareValue = pure $ pure $ pure $ pure 5
-- in the real case, each applicative layer performs actual effects

value2 :: Phases Int 
value2 = coerce bareValue

Alas, suppose we had some other value like this lying around:

-- We don't want this newtype to be coerced, but we might do it by accident.
newtype DoNotUnwrap = DoNotUnwrap Int

dangerousBareValue :: IO (IO (IO (IO DoNotUnwrap)))
dangerousBareValue = pure $ pure $ pure $ pure (DoNotUnwrap 5)

coerce works fine with it:

-- bad!
value3 :: Phases Int 
value3 = coerce dangerousBareValue

What we need is a restricted form of coerce which only removes the Compose newtypes, and nothing more. To express "only removes the Compose" we can use a type family:

type family Bare x where
  Bare (Compose outer inner x) = Bare (outer (Bare (inner x)))
  Bare other = other

With Bare, we can define a restricted coerce and use it like this:

fromBare :: Coercible x (Bare x) => Bare x -> x
fromBare = coerce

value4 :: Phases Int
value4 = fromBare bareValue

-- doesn't compile (good!)
-- value5 :: Phases Int
-- value5 = fromBare dangerousBareValue

Another example. The Servant library expects its handlers to work in ExceptT. But suppose that all your candidate handlers work by returning an Either :

type Handler = Int -> String -> ExceptT String IO Int

eitherHandler :: Int -> String -> IO (Either String Int)
eitherHandler = undefined

-- ugh! tedious to write
manuallyAdaptedHandler :: Handler
manuallyAdaptedHandler i s = ExceptT (eitherHandler i s)

As before, we can use coerce directly:

handler2 :: Handler
handler2 = coerce eitherHandler

but again we have problematic cases:

dangerousEitherHandler :: Int -> String -> IO (Either String DoNotUnwrap)
dangerousEitherHandler = undefined

-- bad!
handler3 :: Handler
handler3 = coerce dangerousEitherHandler

We can play the same type family trick:

type family BareH x where
  BareH (ExceptT e IO r) = IO (Either e r)
  BareH (input -> output) = input -> BareH output
  BareH other = TypeError (Text "unexpedted value at the tip: " 
                            :<>: ShowType other)

fromBareH :: Coercible x (BareH x) => BareH x -> x
fromBareH = coerce

handler4 :: Handler 
handler4 = fromBareH eitherHandler

-- doesn't compile (good!)
-- handler5 :: Handler 
-- handler5 = fromBareH dangerousEitherHandler

The full gist is here.

So, how do you restrain the unbridled power of coerce?

I can’t say I ever had this problem, but could you not use type roles instead?

Yes, one solution would be to ensure that no types are unintentionally coercibe. But I still like the idea of constraining coerce itself, making it say “only these specific newtypes will change here”.