Tutorial: Transparent interfaces in Haskell
This post presents a nifty trick that makes use of type families, datakinds and GADTs.
Have you ever wanted to take a JSON -object and access it in Haskell with minimal effort of only specifying what you're expecting from the value? Then you're on the right page.
{-# LANGUAGE TypeFamilies, DataKinds, GADTs #-}module InterfaceTutorial where
As a starting point you need some interface "outwards" from Haskell. For example, you might have JSON objects, some presentation of values.
data JsValue = VArray [JsValue]| VNum Double| VString Stringderiving (Show)data JsType = TArray | TNum | TString
The JsType is there but it's not used in this article.
Idea of this structure is to be the "type" for the structure
and illustrate that what we're going to build is not that type.
Specifically this post is about terms that map into types.
type FooI = 'AsDoublefoo :: TypeOf FooIfoo = 1.4
You'll be able to make a structure that maps some interface declaration into Haskell types and then associates that with a representation that you're interested about.
foo_val :: JsValuefoo_val = toJs (term :: JsTypeMapTerm FooI) foofoo_dec :: Maybe (TypeOf FooI)foo_dec = fromJs (term :: JsTypeMapTerm FooI) foo_val
There could be several reasons why this might be convenient. You could use this when writing a runtime library for an interpreter. Or if you need to encode/decode things in files or even to query databases. Alternatively it could be useful if you just need some distance between a foreign function interface and your Haskell application.
Here's an another example in whole, note that the interface descriptor is going to determine how the structure is interpreted as a type, not the representation that we're converting it to.
type BaaI = 'AsList AsStringbar :: TypeOf BaaIbar = ["Your dynamic programming language implementation","is my Haskell runtime"]bar_val :: JsValuebar_val = toJs (term :: JsTypeMapTerm BaaI) barbar_dec :: Maybe (TypeOf BaaI)bar_dec = fromJs (term :: JsTypeMapTerm BaaI) bar_val
Now lets go through how this is built.
Interface descriptors
The thing that everything else builds up on is this mapping. It tells how the interface is constructed.
data JsTypeMap= AsDouble| AsInteger| AsList JsTypeMap| AsStringderiving (Show)
Next we have this type-level function that assigns a type to each term that we built.
type family TypeOf j whereTypeOf 'AsDouble = DoubleTypeOf 'AsInteger = IntegerTypeOf ('AsList a) = [TypeOf a]TypeOf 'AsString = String
..That's it. Well not entirely.
The ' takes a term and produces a type of that name.
That's in short what DataKind extension brings in.
Unfortunately we do not have an automated way to drop a lifted type back into a term. For that reason we need this kind of a tool to construct a term that "mirrors" the structure of the type-level term.
data JsTypeMapTerm :: JsTypeMap -> * whereTermDouble :: JsTypeMapTerm 'AsDoubleTermInteger :: JsTypeMapTerm 'AsIntegerTermString :: JsTypeMapTerm 'AsStringTermList :: JsTypeMapTerm a -> JsTypeMapTerm ('AsList a)
With the JsTypeMapTerm we can lower the type-level term and produce the conversion functions, first from Js representation to Haskell representation.
fromJs :: JsTypeMapTerm a -> JsValue -> Maybe (TypeOf a)fromJs TermDouble (VNum a) = Just afromJs TermInteger (VNum a) = Just (floor a)fromJs TermString (VString a) = Just afromJs (TermList a) (VArray xs) = sequence (fmap (fromJs a) xs)fromJs _ _ = Nothing
Then from Haskell representation to Js representation.
toJs :: JsTypeMapTerm a -> TypeOf a -> JsValuetoJs TermDouble num = VNum numtoJs TermInteger num = VNum (fromIntegral num)toJs TermString string = VString stringtoJs (TermList a) xs = VArray (fmap (toJs a) xs)
To be able to write the type-level term back into a term, we're going to use typeclasses for that.
class Term a whereterm :: JsTypeMapTerm ainstance Term 'AsDouble whereterm = TermDoubleinstance Term 'AsInteger whereterm = TermIntegerinstance Term 'AsString whereterm = TermStringinstance Term a => Term ('AsList a) whereterm = TermList term
This last structure is responsible for the: term :: JsTypeMapTerm 'a.
It can be also used to encode the schema and write it out
from your application.
Not too pretty, but checks out
Currently this style of programming is quite verbose, but some post-Haskell language will eventually do it much better. For instance, it's already a joke if it gets compared to Idris or Agda where you just implement in the following functions directly:
TypeOf : JsTypeMap -> TypefromJs : (i:JsTypeMap) -> TypeOf i -> JsValuetoJs : (i:JsTypeMap) -> JsValue -> TypeOf i
This trick is definitely worthwhile to know if you want to use Haskell to conveniently interface with something through some protocol.
Update 2020-07-29: Posted this to r/haskell because it might be a good motivation for Purescript developers to notice these extensions and make use of them.
About typeclasses as an alternative
2020-07-31: u/zarazek remarked that you could also use typeclasses directly, like this:
class JsEnc a wheretoJs2 :: a -> JsValuefromJs2 :: JsValue -> Maybe ainstance JsEnc Double wheretoJs2 s = VNum sfromJs2 (VNum n) = Just nfromJs2 _ = Nothinginstance JsEnc Integer wheretoJs2 s = VNum (fromIntegral s)fromJs2 (VNum n) = Just (floor n)fromJs2 _ = Nothinginstance {-# OVERLAPPING #-} JsEnc String wheretoJs2 s = VString sfromJs2 (VString s) = Just sfromJs2 _ = Nothinginstance JsEnc a => JsEnc [a] wheretoJs2 xs = VArray (fmap toJs2 xs)fromJs2 (VArray xs) = sequence (fmap fromJs2 xs)fromJs2 _ = Nothing
Note the String is a type alias for [Char].
This means you're going to need FlexibleInstances enabled
and prefix it with {-# OVERLAPPING #-}.
Without this the compiler is going to have issue with overlapping instances.
We're looking at something really similar here:
toJs :: JsTypeMapTerm a -> TypeOf a -> JsValuetoJs2 :: JsEnc a => a -> JsValuefromJs :: JsTypeMapTerm a -> JsValue -> Maybe (TypeOf a)fromJs2 :: JsEnc a => JsValue -> Maybe a
You could've also used a GADT as a map.
data JsTypeMap2 :: * -> * whereAsDouble2 :: JsTypeMap2 DoubleAsInteger2 :: JsTypeMap2 IntegerAsList2 :: JsTypeMap2 a -> JsTypeMap2 [a]AsString2 :: JsTypeMap2 Stringderiving instance Show (JsTypeMap2 a)
And we get a third way to convert values into JSON.
toJs :: JsTypeMapTerm a -> TypeOf a -> JsValuetoJs2 :: JsEnc a => a -> JsValuetoJs3 :: JsTypeMap2 a -> a -> JsValue
If you'd like to make it very similar to typeclasses, you could use the following constructor that requires you to pass in the implementation:
data JsTypeMap3 :: * -> * whereGenMap :: (JsValue -> Maybe a)-> (a -> JsValue)-> JsTypeMap3 atoJs4 :: JsTypeMap3 a -> a -> JsValuetoJs4 (GenMap fromJ toJ) = toJfromJs4 :: JsTypeMap3 a -> JsValue -> Maybe afromJs4 (GenMap fromJ toJ) = fromJ
The typeclasses implicitly find the conversion so when the conversion is unique against a Haskell type, you likely will find it sufficient. Otherwise you're better off using a GADT or type families like presented in this article.
Now you have few ways to do this same thing. Thank you to u/zarazek for this.