Custom Type Errors for Unordered Function Application
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)
• of ‘?’
arising from a use 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))
• of ‘?’
arising from a use Due to no instance for (ApplyByType matches3 Int Text)
• :
arising from superclass constraintsApplyByType matches1 Bool Text =>
ApplyByType matches2 Bool (Text -> Text) =>
ApplyByType matches3 Bool (Char -> Text -> Text)
with~ MatchFirstArg Bool Text ~ 'NoArgToMatch
matches1 ~ MatchFirstArg Bool (Text -> Text) ~ Doesn'tMatch
matches2 ~ MatchFirstArg Bool (Char -> Text -> Text) ~ Doesn'tMatch
matches3 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)
= error "impossible"
applyByTypeImpl
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:
Text
will output the providedSymbol
(type level string).ShowType
will render the type as a string in the output message.(:<>:)
horizontally concatenates messages.(:$$:)
vertically concatenates messages.
So, with this definition, the above type error becomes:
> cons ? True
<interactive>:1:1: error:
Parameter type
• Bool
not occur in the arguments of the function that returns
does 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
( ~ MatchFirstArg a f
, matches 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
not occur in the arguments of the function type
does 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.