Oddity of type checker or bug?

Don’t look too much into what this code does, it’s just a small example of the behavior I was a little surprised at.

Two implementations of anyShow, one using TypeEquals and the other not. Sometimes they act the same, and sometimes they don’t. I’m guessing test4 works fine because it’s at the top level, whereas in test6 it is not, and the bidirectional type checker is doing something different for each as there’s different contexts?

module Main where

import Prelude

import Unsafe.Coerce
import Type.Proxy
import Type.Equality

anyShow1 :: forall a. Proxy a -> (Show a => String) -> String
anyShow1 _ f = (coerce f) { show: \_ -> "Shown" }
  where
    coerce :: (Show a => String) -> ({ show :: a -> String } -> String)
    coerce = unsafeCoerce
    
anyShow2 :: forall a x. TypeEquals x (Show a => String) => Proxy a -> x -> String
anyShow2 _ f = (coerce f) { show: \_ -> "Shown" }
  where
    coerce :: x -> ({ show :: a -> String } -> String)
    coerce = unsafeCoerce

data V = V

test1 = anyShow1 (Proxy :: _ V)

-- same type inferred for test2 as test1
test2 = anyShow2 (Proxy :: _ V)

test3 = test1 (show V)

test4 = test2 (show V)

test5 = anyShow1 (Proxy :: _ V) (show V)

-- doesn't work
--   Could not match constrained type `Show V => String` with type `String`
-- test6 = anyShow2 (Proxy :: _ V) (show V)

-- but does work with an explicit annotation
test7 = (anyShow2 (Proxy :: _ V) :: (Show V => String) -> String) (show V)

I think it’s an issue of the order of instances being resolved, so my guess is:

When passing show V into anyShow2, the typechecker assumes that the RHS of constraints is what was intended to be passed in, as it only sees the vague type variable x, and thus implies String ~ x

But then it tries to solve TypeEquals, and that implies (Show V => String) ~ x. So really there are two errors, not being able to match Show V => String with String, and no instance found for Show V

Your intuition is correct. In the compiler typechecking/elaboration happens before constraint solving. It will use information from anyShow to decide how to elaborate show V. When it has a concrete type to latch on to Show V => String (like anyShow), it knows that it should elaborate show V to include a function argument accepting the Show V dictionary. TypeEquals defers something to the constraint solver. There’s nothing special about TypeEquals. To the compiler it’s just an arbitrary constraint. It’s not a primitive constraint or anything, like in GHC. All the typechecker is seeing then is that it should be x as chosen by the caller. Without an additional signature, the compiler sees show V as String. It then instantiates the signature of anyShow2 with String as x. It then gets kicked to the constraint solver, where it tries to solve TypeEquals String (Show a => String), which cannot be solved.

The behavior here is correct. We must know all the constraints to search for before we can start searching for them! Elaboration must happen before constraint solving. In general, constraint solving does not work to compute polytypes. If you tried to write an instance for something, and used a constrained type in an instance head, I believe the compiler would reject it. I think the real issue is that it should probably reject the constrained type in the TypeEquals RHS.

3 Likes