I have these two functions:
load :: Asset a => Reference -> IO (Maybe a)
send :: Asset a => a -> IO ()
The Asset class look like this:
class (Typeable a,ToJSON a, FromJSON a) => Asset a where
ref :: a -> Reference
...
The first reads an asset from disk, and the second transmits a JSON representation to a WebSocket. In isolation they work fine, but when I combine them the compiler cannot deduce what concrete type a
should be. (Could not deduce (Asset a0) arising from a use of 'load'
)
This makes sense, I have not given a concrete type and both load
and send
are polymorphic. Somehow the compiler has to decide which version of send
(and by extension what version of toJSON
) to use.
I can determine at run time what the concrete type of a
is. This information is actually encoded both in the data on the disk and the Reference
type, but I do not know for sure at compile time as the type checker is being run.
Is there a way to pass the correct type at run time an still keep the type checker happy?
Additional Information
The definition of Reference
data Reference = Ref {
assetType:: String
, assetIndex :: Int
} deriving (Eq, Ord, Show, Generic)
References are derived by parsing a request from a WebSocket as follows where Parser comes from the Parsec library.
reference :: Parser Reference
reference = do
t <- string "User"
<|> string "Port"
<|> string "Model"
<|> ...
char '-'
i <- int
return Ref {assetType = t, assetIndex =i}
If I added a type parameter to Reference
I simply push my problem back into the parser. I still need to turn a string that I do not know at compile time into a type to make this work.
You can't make a function that turns string data into values of different types depending on what is in the string. That's simply impossible. You need to rearrange things so that your return-type doesn't depend on the string contents.
Your type for load
, Asset a => Reference -> IO (Maybe a)
says "pick any a
(where Asset a
) you like and give me a Reference
, and I'll give you back an IO
action that produces Maybe a
". The caller picks the type they expect to be loaded by the reference; the contents of the file do not influence which type is loaded. But you don't want it to be chosen by the caller, you want it to be chosen by what's stored on disk, so the type signature simply doesn't express the operation you actually want. That's your real problem; the ambiguous type variable when combining load
and send
would be easily resolved (with a type signature or TypeApplications
) if load
and send
were individually correct and combining them was the only problem.
Basically you can't just have load
return a polymorphic type, because if it does then the caller gets to (must) decide what type it returns. There's two ways to avoid this that are more-or-less equivalent: return an existential wrapper, or use rank 2 types and add a polymorphic handler function (continuation) as a parameter.
Using an existential wrapper (requires GADTs
extension), it looks something like this:
data SomeAsset
where Some :: Asset a => a -> SomeAsset
load :: Reference -> IO (Maybe SomeAsset)
Notice load
is no longer polymorphic. You get a SomeAsset
that (as far as the type checker is concerned) could contain any type that has an Asset
instance. load
can internally use whatever logic it wants split into multiple branches and come up with values of different types of asset on different branches; provided each branch ends with wrapping up the asset value with the SomeAsset
constructor all of the branches will return the same type.
To send
it, you would use something like (ignoring that I'm not handling Nothing
):
loadAndSend :: Reference -> IO ()
loadAndSend ref
= do Just someAsset <- load ref
case someAsset
of SomeAsset asset -> send asset
The SomeAsset
wrapper guarantees that Asset
holds for its wrapped value, so you can unwrap them and call any Asset
-polymorphic function on the result. However you can never do anything with the value that depends on the specific type in any other way1, which is why you have to keep it wrapped up and case
match on it all the time; if the case
expression results in a type that depends on the contained type (such as case someAsset of SomeAsset a -> a
) the compiler will not accept your code.
The other way is to instead use RankNTypes
and give load
a type like this:
load :: (forall a. Asset a => a -> r) -> Reference -> IO (Maybe r)
Here load
doesn't return a value representing the loaded asset at all. What it does instead is take a polymorphic function as an argument; the function works on any Asset
and returns a type r
(that was chosen by load
's caller), so again load
can internally branch however it wants and construct differently-typed assets in the different branches. The different asset types can all be passed to the handler, so the handler can be called in every branch.
My preference is often to use the SomeAsset
approach, but then to also use RankNTypes
and define a helper function like:
withSomeAsset :: (forall a. Asset a => a -> r) -> (SomeAsset -> r)
withSomeAsset f (SomeAsset a) = f a
This avoids having to restructure your code into continuation passing style, but takes away the heave case
syntax everywhere you need to use a SomeAsset
:
loadAndSend :: Reference -> IO ()
loadAndSend ref
= do Just asset <- load ref
withSomeAsset send asset
Or even add:
sendSome = withSomeAsset send
Daniel Wagner suggested adding the type parameter to Reference
, which the OP objected to by stating that simply moves the same problem to when the references are constructed. If the references contain data representing which type of asset they refer to, then I would strongly recommend taking Daniel's advice, and using the concepts described in this answer to address that problem at the reference-constructing level. Reference
having a type parameter prevents mixing up references to the wrong types of assets where you do know the type.
And if you do significant processing with references and assets of the same type, then having the type parameter in your workhorse code can catch easy mistakes mixing them up even if you usually existential the type away at the outer levels of code.
1 Technically your Asset
implies Typeable
, so you can test it for specific types and then return those.