Custom Type Errors for Unordered Function Application

mgsloan


In the previous post, a combination of a typeclass, closed type family, and associated type family was used to define an operator, (?). This operator provides type-directed function application, where the argument is provided to the first parameter that has a matching type.

This post will describe improving some of the type errors that can occur when using this operator, by using GHC's custom type errors feature.

Argument mismatch error

With the definitions in the previous post, usage of (?) with an argument that has no matching parameter results in unclear type errors:

> cons ? True

<interactive>:1:1: error:
No instance for (ApplyByType 'NoArgToMatch Bool Text)
        arising from a use of?
In the expression: cons ? True
      In an equation for ‘it’: it = cons ? True

In order for the programmer to understand this error, they would need to know exactly how the type-level machinery around ApplyByType is structured, and then simulate this machinery in their head. It would be nice if GHC was more informative for cases like this. "Inspecting Haskell Instance Resolution" suggests some ways this could be improved. Instead the error might be:

<interactive>:1:1: error:
No instance for (ApplyByType matches3 Bool (Char -> Text -> Text))
        arising from a use of?
Due to no instance for (ApplyByType matches3 Int Text)
      arising from superclass constraints:
        ApplyByType matches1 Bool Text =>
        ApplyByType matches2 Bool (Text -> Text) =>
        ApplyByType matches3 Bool (Char -> Text -> Text)
      with
        matches1 ~ MatchFirstArg Bool Text ~ 'NoArgToMatch
        matches2 ~ MatchFirstArg Bool (Text -> Text) ~ Doesn'tMatch
        matches3 ~ MatchFirstArg Bool (Char -> Text -> Text) ~ Doesn'tMatch
In the expression: cons ? True
      In an equation for ‘it’: it = cons ? True

This makes it clear that the typeclass machinery recursed through the function type and determined that none of the arguments matched.

Even just reporting that the instance directly used by (?) is (ApplyByType Doesn'tMatch Bool (Char -> Text -> Text) helps clarify the issue.

Better errors via custom type errors

Such inspection of type-level machinery does not currently exist, so the only viable approach to improving this error is through use of GHC's custom type errors.

Here's a first try at doing this:

instance TypeError (NoMatchForResultError a r)
      => ApplyByType 'NoArgToMatch a r where
  type ApplyByTypeResult 'NoArgToMatch a r =
    TypeError (NoMatchForResultError a r)
  applyByTypeImpl = error "impossible"

type NoMatchForResultError a r =
  'Text "Parameter type " ':$$:
  'Text "  " ':<>: 'ShowType a ':$$:
  'Text "does not occur in the arguments of the function that returns " ':$$:
  'Text "  " ':<>: 'ShowType r ':$$:
  'Text "and so cannot be applied via type directed application.

This instance for the NoArgToMatch case has a TypeError in its superclass constraint. Now, rather than reporting a missing instance for this case, the custom type error message will be shown. This message is defined by NoMatchForResultError.

Here's how the various constructors for the error message work:

So, with this definition, the above type error becomes:

> cons ? True

<interactive>:1:1: error:
Parameter type
        Bool
      does not occur in the arguments of the function that returns
        Text
      and so cannot be applied via type directed application.
When checking the inferred type
        it :: Char -> Text -> (TypeError ...)

Better! This version of the code is didactic/V4.hs on github.

Reporting the full function type

Above, the type error above only reports the last return value of the function. It would be clearer to show the full function type, so that the programmer can directly see that none of the parameters have a type which matches the argument.

One way to approach this is to have a closed type family which yields the error when it finds no matching argument:

type family HasAMatch a f f0 :: Constraint where
  HasAMatch a (a -> r) f0 = ()
  HasAMatch a (b -> r) f0 = HasAMatch a r f0
  HasAMatch a _ f0 = TypeError (NoMatchErrorMsg a f0)

type NoMatchErrorMsg a f =
  'Text "Parameter type " ':$$:
  'Text "  " ':<>: 'ShowType a ':$$:
  'Text "does not occur in the arguments of the function type " ':$$:
  'Text "  " ':<>: 'ShowType f ':$$:
  'Text "and so cannot be applied via type directed application."

(?)
  :: forall matches a f.
     ( HasAMatch a f f
     , matches ~ MatchFirstArg a f
     , ApplyByType matches a f
     )
  => f -> a -> ApplyByTypeResult matches a f
(?) = applyByTypeImpl (Proxy :: Proxy matches)

Similarly to the ApplyByType typeclass, the first argument is the argument to match, and the second argument is the function type being recursed on. The added clarity in the error message comes from passing down the third argument, which has the full function type.

Now the type error is much nicer:

> cons ? (1 :: Int)

<interactive>:1:1: error:
Parameter type
        Bool
      does not occur in the arguments of the function type
        Char -> Text -> Text
      and so cannot be applied via type directed application.
In the expression: cons ? (1 :: Int)
      In an equation for ‘it’: it = cons ? (1 :: Int)

This version of the code is didactic/V5.hs on github. Note that with this approach, the instance ApplyByType 'NoArgToMatch a r is no longer necessary because we handle that case using the HasAMatch closed type family.

That's it for now, thanks for reading! The next post in this series uses ? to implement a reorderArgs function which will permute function arguments to match a desired type signature.


More words

Type-Directed Permutation of Function Parameters

Variadic functions + unordered application. Read