I don't see why you're using a typeclass in the first place. What does a typeclass gain you over just having normal functions?
Just define binary operators as, well, Haskell binary operators which are just normal functions:
f :: MyType -> MyType -> MyType
f = ...
Since all your DSL types are in MyType
, there's no reason to use a typeclass.
Packing and Unpacking
Of course, this still doesn't solve your error
problem. One approach I've taken in the past is to use typeclasses to define ways to "pack" and "extract" primitive types into your DSL:
class Pack a where
pack :: a -> MyType
class Extract a where
extract :: MyType -> a
Here's what the instance for String
would look like:
instance Pack String where pack = A
instance Extract String where
extract (A str) = str
extract _ = error "Type error: expected string!"
The Extract
class can deal with error handling for incompatible types.
This lets you uniformly "lift" functions into your DSL:
-- Lifts binary Haskell functions into your DSL
lift :: (Extract a, Extract b, Pack c) => (a -> b -> c)
-> MyType -> MyType -> MyType
lift f a b = pack $ f (extract a) (extract b)
If you make MyType
an instance of Pack
and Extract
, this will work for both purely Haskell functions and functions aware of your DSL. That said, the aware functions will just get some sort of MyType
and will have to deal with it manually, calling error
if their MyType
argument isn't what they expected.
So this solves your error
problem for functions you can write in straight Haskell but not really for ones that depend on MyType
.
Error Handling
Using pack
is also nice because it's pretty straightforward to switch to a better error-handling mechanism than error
. You would just switch the type of extract
(or even pack
, if appropriate). Maybe you could use:
class Extract a where
extract :: MyType -> Either MyError a
and then fail with Left (TypeError expected got)
which would let you write nice error messages.
This would also let you easily combine multiple primitive functions into "cases" at the MyType
level. The basic idea is that we combine multiple liftable functions into a single MyType -> MyType -> MyType
and internally we just use the first one that doesn't give us an error. This can also give us some pretty looking syntax :).
Here's the relevant code:
type MyFun = MyType -> MyType -> Either MyError MyType
(|:) :: (Extract a, Extract b, Pack c) => MyFun -> (a -> b -> c) -> MyFun
(f |: option) a b = case f a b of
Right res -> return res
Left err -> (lift option) a b
match :: MyFun
match _ _ = Left EmptyFunction
test = match |: (\ a b -> a ++ b :: String)
|: (\ a b -> a || b)
Unfortunately, I had to add a :: String
type signature because it was ambiguous otherwise. The same would happen if I use +
, since it doesn't know what kind of number to rely on.
Now test
is a function which works correctly on two A
s or two B
s and gives an error otherwise:
*Main> test (A "foo") (A "foo")
Right (A "foofoo")
*Main> test (C True) (C False)
Right (C True)
*Main> test (A "foo") (C False)
Left TypeError
Also note that this would work perfectly happily on different types of arguments, like a case which could combine A
and B
values.
This means that you can now conveniently recast your f
, g
, h
and so on functions as top-level names in Haskell. Here is how you could define f
:
f :: MyFun
f = match |: \ s1 s2 -> {- something with strings -}
|: \ s i -> {- something with a string and an int -}
|: \ i d -> {- something with an int and a double -}
|: {- ...and so on... -}
You will sometimes have to annotate some of the values with type signatures because there isn't always enough information to make type inference work properly. This should only come up if you use operations from typeclasses (ie +
) or use operations with more general types like ++
for strings (++
can work on any lists).
You'd also have to update lift
to handle the errors properly. This involves changing it to return an Either
and adding the necessary plumbing. My version looks like this:
lift :: (Extract a, Extract b, Pack c) => (a -> b -> c) -> MyFun
lift f a b = fmap pack $ f <$> extract a <*> extract b
Newtypes
This mostly solves your error
problem by having the |:
construct check errors for you. The main weakness with this approach is that it won't work very well if you want your DSL to have multiple types that have the same underlying Haskell type like:
data MyType = A Double
| B Double
{- ... -}
You could fix this by using newtype
to create a wrapper for Double
. Something like this:
newtype BDouble = B Double
instance Pack Double where pack = A
instance Pack BDouble where pack = B
-- same for Extract