reasonablypolymorphic.com/some1-like-you
We will make a simple data-ingestion platform for our life-tracking app.
The program will provide an API with a unique endpoint for each distinct type of data we can ingest.
The marketing team says that by the end of the year, we'll have over 500 different "events" we'll want to be able to ingest.
Boilerplate is boring to write and easy to get wrong.
data Event = WakeUp | Eat Meal | RockOut Song Duration
instance FromJSON Event where parseJSON = parseWakeUp <|> parseEat <|> parseRockOut
type Req = ReqBody '[JSON] Value type Resp = Post '[JSON] Response type EventAPI = "api" :> "event" :> ( "wake-up" :> Req :> Resp :<|> "eat" :> Req :> Resp :<|> "rock-out" :> Req :> Resp )
importEvent :: Value -> ExceptT ServantErr IO Response importEvent blob = case fromJSON blob of Error err -> throwM err Success ev -> pure $ Response ev
wakeUp = importEvent eat = importEvent rockOut = importEvent eventServer :: Server EventAPI eventServer = serve $ wakeUp :<|> eat :<|> rockOut
Notice how there is no type safety here.
Our wake-up endpoint will happily accept a eat payload.
Separate the constructors of our sum type into their own types.
data PayloadWakeUp = PayloadWakeUp data PayloadEat = PayloadEat Meal data PayloadRockOut = PayloadRockOut Song Duration instance FromJSON PayloadWakeUp instance FromJSON PayloadEat instance FromJSON PayloadRockOut
{-# LANGUAGE TemplateHaskell #-} data Event = EventWakeUp PayloadWakeUp | EventEat PayloadEat | EventRockOut PayloadRockOut makePrisms ''Event
We can use these prisms to lift our payload types into our Event type.
{-# LANGUAGE RankNTypes #-} importEvent :: FromJSON e => Prism' Event e -> Value -> ExceptT ServantErr IO Response importEvent prism blob = case fromJSON blob of Error err -> throwM err Success e -> pure . Response $ review prism e
wakeUp = importEvent _PayloadWakeUp eat = importEvent _PayloadEat rockOut = importEvent _PayloadRockOut eventServer :: Server EventAPI eventServer = serve $ wakeUp :<|> eat :<|> rockOut
The endpoints will no longer accept payloads of the wrong type.
The compiler doesn't know that our new payload types are related.
Grouping our payload types together might provide opportunities for more clever tricks.
On data kinds and type families.
Data kinds lifts values to types, and types to kinds.
data Bool = True | False
begets, via DataKinds:
kind Bool where type 'True type 'False
A type family is a function that returns a type.
type family NotInt t where NotInt Int = () NotInt a = a foo :: NotInt Bool foo = True bar :: NotInt Int bar = ()
Type families only exist at the type level.
We can write type families over DataKinds.
{-# LANGUAGE DataKinds #-} {-# LANGUAGE TypeFamilies #-} data EventType = WakeUp | Eat | RockOut
data family Payload (e :: EventType)
data instance (Payload 'WakeUp) = PayloadWakeUp data instance (Payload 'Eat) = PayloadEat Meal data instance (Payload 'RockOut) = PayloadRockOut Song Duration instance FromJSON (Payload 'WakeUp) instance FromJSON (Payload 'Eat) instance FromJSON (Payload 'RockOut)
Armed with this type family, we can get our old sum type for free.
{-# LANGUAGE GADTs #-} data Event where MkEvent :: Payload (et :: EventType) -> Event
{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE ScopedTypeVariables #-} importEvent :: forall (et :: EventType) . FromJSON (Payload et) => Value -> ExceptT ServantErr IO Response importEvent blob = case fromJSON blob of Error err -> throwM err Success (e :: Payload et) -> pure . Response $ MkEvent e
{-# LANGUAGE TypeApplications #-} wakeUp = importEvent @'WakeUp eat = importEvent @'Eat rockOut = importEvent @'RockOut eventServer :: Server EventAPI eventServer = serve $ wakeUp :<|> eat :<|> rockOut
Notice that we've eliminated some boilerplate.
We no longer need to keep our Event type in sync with the payload types.
Generating the API definition automatically would remove a lot more boilerplate.
The EventType now exists at the value level.
type Req = ReqBody '[JSON] Value
type Resp = Post '[JSON] Response
type EventAPI =
"api" :>
"event" :>
Capture "event-type" EventType :> Req :> Resp
importEvent :: EventType -> Value -> ExceptT ServantErr IO Response importEvent et blob = case fromJSON blob of Error err -> throwM err Success (e :: Payload et) -> pure . Response $ MkEvent e
No instance for (FromJSON (Payload et))
arising from a use of `fromJSON'
Huh??
On singletons.
() :: ()
If you know what value you have, you know its type, and vice-versa.
We'll introduce a new type for each value we'd like to move to the type level.
But it's not.
Unfortunately, not the same types as provided by DataKinds.
{-# LANGUAGE PolyKinds #-} {-# LANGUAGE TypeInType #-} data family Sing (a :: k) class SingKind k where fromSing :: Sing (a :: k) -> k toSing :: k -> SomeSing k
data instance (Sing 'True) = STrue data instance (Sing 'False) = SFalse instance SingKind Bool where fromSing s = case s of STrue -> True SFalse -> False toSing b = case b of True -> SomeSing STrue False -> SomeSing SFalse
singletons [d| data Bool = True | False |]
singletons [d| data EventType = WakeUp | Eat | RockOut |]
withSomeSing :: SingKind k => k -> (forall (a :: k). Sing a -> r) -> r
Armed with this knowledge, we can lift our EventType value into the type system!
importEvent :: EventType -> Value -> ExceptT ServantErr IO Response importEvent etype blob = withSomeSing etype $ \ (_ :: Sing et) -> case fromJSON blob of Error err -> throwM err Success (e :: Payload et) -> pure . Response $ MkEvent e
No instance for (FromJSON (Payload et))
arising from a use of `fromJSON'
Huh?????????????
Didn't we fix this?
We know that FromJSON is total over Payload.
But how can we prove it?
dictFromJSON :: ( FromJSON (Payload 'WakeUp) , FromJSON (Payload 'Eat) , FromJSON (Payload 'RockOut) ) => Sing (a :: EventType) -> Dict (FromJSON (Payload a))
A Dict c is a proof that we have the constraint c.
dictFromJSON :: ( FromJSON (Payload 'WakeUp) , FromJSON (Payload 'Eat) , FromJSON (Payload 'RockOut) ) => Sing (a :: EventType) -> Dict (FromJSON (Payload a)) dictFromJSON s = case s of SWakeUp -> Dict SEat -> Dict SRockOut -> Dict
importEvent :: EventType -> Value -> ExceptT ServantErr IO Response importEvent etype blob = withSomeSing etype $ \ (setype :: Sing et) -> case dictFromJSON setype of Dict -> case fromJSON blob of Error err -> throwM err Success (e :: Payload et) -> pure . Response $ MkEvent e
eventServer :: Server EventAPI
eventServer = serve importEvent
It is now impossible to incorrectly hook up a new EventType:
We also want to serialize these new events into a single pipe for downstream consumption.
For simplicitly we'll also use JSON going downstream.
dictToJSON :: ( ToJSON (Payload 'WakeUp) , ToJSON (Payload 'Eat) , ToJSON (Payload 'RockOut) ) => Sing (a :: EventType) -> Dict (ToJSON (Payload a)) dictToJSON s = case s of SWakeUp -> Dict SEat -> Dict SRockOut -> Dict
Besides the constraints under consideration, dictToJSON is identical to dictFromJSON.
{-# LANGUAGE ConstraintKinds #-} dictPayload :: ( c (Payload 'WakeUp) , c (Payload 'Eat) , c (Payload 'RockOut) ) => Sing (a :: EventType) -> Dict (c (Payload a)) dictPayload s = case s of SWakeUp -> Dict SEat -> Dict SRockOut -> Dict
We can now lift any constraint that is total over Payload.
Let's use it to implement ToJSON over Events.
instance ToJSON Event where toJSON (MkEvent payload) = toJSON payload
No instance for (ToJSON (Payload et))
arising from a use of `toJSON'
Oh yeah. It doesn't lift automatically.
But we don't have one.
But we used to!
data Event where MkEvent :: Sing (et :: EventType) -> Payload et -> Event
instance ToJSON Event where toJSON (MkEvent setype payload) = case dictPayload @ToJSON setype of Dict -> object [ "type" .= fromSing setype , "payload" .= payload ]
We can write a similar FromJSON instance.
But what can we take away?
In the literature, the combination of a value and a type that depends on that type is known as a dependent pair.
We can write the type of a dependent pair like this:
$$\sum_\text{a :: EventType} \text{Payload}(a)$$
$$\sum_\text{a :: EventType} \text{Payload}(a) = \text{Payload}(a_1) + \text{Payload}(a_2) + \cdots + \text{Payload}(a_n)$$
data Event = EventWakeUp (Payload WakeUp) | EventEat (Payload Eat) | EventRockOut (Payload RockOut)
This type is perfectly captured by the dependent pair.
$$(a, b) :: \sum_\text{a :: A} \text{F}(a)$$
We can encode this directly in Haskell.
data Some1 (f :: k -> Type) where Some1 :: Sing (a :: k) -> f a -> Some1 f
type Event = Some1 Payload
We can generalize our dictPayload function as well:
class Dict1 (c :: output -> Constraint) (f :: input -> output) where dict1 :: Sing (a :: input) -> Dict (c (f a))
All of this machinery has already been built for you!
https://hackage.haskell.org/package/exinst
It also provides instances lifting Dict1 over Some1, as well as tons of other goodies.
Space | Forward |
---|---|
Right, Down, Page Down | Next slide |
Left, Up, Page Up | Previous slide |
P | Open presenter console |
H | Toggle this help |