Managing I/O in Purescript apps
Felix Schlitter, software engineer at DN3010
What's Purescript?
- Pure functional programming language that targets Javascript
- Statically and strongly typed
- Haskell inspired syntax
- Advanced type system:
- Algebraic data types and pattern matching
- Row polymorphism and extensible records
- Higher kinded types
- Type classes with functional dependencies
- Higher-rank polymorphism
- Reflection
Hello world in Purescript
import Prelude
import Control.Monad.Eff.Console (CONSOLE)
import Control.Monad.Eff.Console as Console
greet :: String -> String
greet name = "Hello, " <> name <> "!"
main :: ∀ eff. Eff (console :: CONSOLE | eff) Unit
main = Console.log (greet "World")
What makes Purescript awesome?
- Type-guided development
- Separates pure from impure code
- Composable, declarative, general
- Fast compiler / good tooling
- Helpful, active community
- Immutable
- Easy to refactor
- Runs anywhere Javascript runs
- Convenient and dead simple FFI
- Compile time checked totality of functions
What makes Purescript awesome?
- Discover a functions true (co-)domain
head :: ∀ a. List a -> a
head (Cons x) = x
head Nil = ? -- Oops, no way to implement this!
Type-guided development
- Partial type signatures
main :: forall e. Eff _ Unit
main = Console.log "foobar"
head :: ∀ a. List a -> Maybe a
head (Cons x) = Just x
head Nil = Nothing
head :: ∀ a. Partial => List a -> a
head (Cons x) = x
Partial functions only pass compiler if marked as such
main :: ∀ eff. Eff (console :: CONSOLE | eff) Unit
main = Console.log $ show $
unsafePartial $ head Nil
-- ^ We *MUST* explicitly ask for a crash
What makes Purescript awesome?
Separation of pure from impure code
doEvil :: Eff _ Unit
doEvil = FS.removeSync "/home"
calcSquare :: Int -> Int
calcSquare x =
let _ = doEvil -- does nothing!
in x * x
I/O can only occur in the Eff Monad
- I/O is represented as thunks
- Thunks only forced in the Eff Monad
So... Why would you not want to use Purescript?
- Still no version 1.0 yet
- You are largely self-reliant, off the beaten track
- Complicated types + Time pressure = Disaster
- Performance can suffer
- Deep monad stacks
- Large number of binds
- Stack-traces are largely useless
- Fewer off-the-shelve libraries
- Package management is based on Bower
- Package-sets are in the works
Building apps in Purescript
There is no officially prescribed or even recommended way to build apps in Purescript.
However, the most popular libraries in terms of uptake are [0]:
- purescript-halogen
- purescript-pux
- purescript-thermite
- purescript-react
[0] since Purescript currently uses bower, all libraries are namespaced with a "purescript-" prefix
Advantages of simple bindings to React
We get to use an awesome language on top of proven technology:
- React is production ready
- Known performance characteristics
- Re-use existing components
- Gradually transition codebase to Purescript
- Easier to onboard new developers
- Bonus: Monadic HTML builder for awesome syntax
- Bonus: Let's us target React Native today
Monadic markup vs JSX
JSX is cool, but in Purescript we have no need for it.
render () {
return <div>
<h1 className="title">
Dashboard
</h1>
{ isLoggedIn &&
<button onClick={() => { /* ... */ }}>
Log out
</button>
}
</div>
}
render this = renderIn div do
h1
! className "title"
$ text "Dashboard"
when isLoggedIn do
button
! onClick (\event -> {- ... -})
$ text "Log out"
The React + Redux architecture
reducer
render
I/O
In Purescript this architecture has some extra benefit
-
Pure code cannot crash
- No crashes in reducer
- No crashes in renderer
- Functions are checked for totality:
- Never again "undefined" and "null"
- Remaining bugs are therefore due to:
- Essential complexity
- Too broad function domains
- Hence, the only worry is I/O
Approaching I/O
The redux-saga [0] project pioneered a novel way of dealing with I/O:
- Move all I/O into a separate program
- Strictly no I/O in reducer, render function or action creators
- All I/O runs in a ES6 coroutine:
- Pull events
- Push actions
Approaching I/O
Meet purescript-redux-saga
saga :: ∀ env state. Saga env Action state Unit
saga = forever do
take case _ of
LoginRequest { username, password } -> Just do
result <- liftAff $ attempt $ authenticate username password
case result of
Left err -> put $ LoginFailure err
Right token -> put $ LoginSuccess { username, token }
_ -> Nothing
data Action
= LoginRequest { username :: String, password :: String }
| LoginFailure Error
| LoginSuccess { username :: String, token :: String }
A giant, glorified, effectful left-fold over events accumulating into redux state
The "Saga" Monad
-- read-only environment, accessible via `MonadAsk` instance
-- / your state container type (reducer)
-- | / the type this saga consumes (i.e. actions)
-- | | / the type of output this saga produces (i.e. actions)
-- | | | / the return value of this Saga
-- | | | | /
newtype Saga' env state input output a = Saga' ...
type MyState = { currentUser :: String }
logUser :: ∀ env input output. Saga' env MyState input output Unit
logUser = void do
{ currentUser } <- getState
liftIO $ Console.log currentUser
The "state" parameter enables us read the current application state.
type MyConfig = { apiUrl :: String }
logApiUrl :: ∀ state input output. Saga' MyConfig state input output Unit
logApiUrl = void do
{ apiUrl } <- ask
liftIO $ Console.log apiUrl
The "env" type parameter enables us to implicitly pass read-only configuration to our Sagas.
getTime :: ∀ env state input output. Saga' env state input output Int
getTime = liftEff $ Date.now
The "return value" parameter enables us to return values from a Saga
data MyAction
= LoginRequest Username Password
| LogoutRequest
loginFlow :: ∀ env state output. Saga' env state MyAction output Unit
loginFlow = forever do
take case _ of
LoginRequest user pw -> do
...
_ -> Nothing -- ignore other actions
The "input" parameter enables us to process inputs (e.g. actions)
data MyAction
= LoginRequest Username Password
| LogoutSuccess Username
| LogoutFailure Error
| LogoutRequest
loginFlow :: ∀ env state input. Saga' env state input MyAction Unit
loginFlow = forever do
take case _ of
LoginRequest user pw -> do
result <- liftAff $ attempt ...
case result of
Left err -> put $ LoginFailure err
Right v -> put $ LoginSuccess user
_ -> Nothing -- ignore other actions
The "output" parameter enables us to dispatch actions to the reducer
Composable I/O
As long as the types line up, Sagas can be arbitrarily composed
withGrant
:: ∀ state input output a
. Service
-> (Grant -> Saga' Env state input output a)
-> Saga' Env state input output a
withGrant service action = do
{ authenticator } <- ask
action =<< liftAff (Auth.grant service authenticator)
Example: Ensure authenticated access to APIs
fetchOwnAccount :: ∀ state input output. Saga' Env state input output Unit
fetchOwnAccount = do
{ apiUrl } <- ask
{ currentUser: { id: userId } <- getState
withGrant "edge" $ API.fetchOwnAccount apiUrl userId
type Env =
{ authenticator :: ClientAuthenticator
, currentUser :: { id :: String }
}
Forks and channels
Sagas can be forked
- Forked Sagas do not block the current Saga
- Sagas won't terminate until all forks have terminated
- Errors bubble up and cause termination of the containing Saga
postLoginFlow :: ∀ env state input output Unit
postLoginFlow = do
task <- fork $ renewAuthPeriodically
void $ fork $ watchForIncomingCalls
void $ fork $ autoReconnectWS
...
renewAuthPeriodically :: ∀ env state input output Unit
renewAuthPeriodically = forever do
void $ iterateUntil id do
liftAff (attempt $ authenticate ...) >>= case _ of
Left _ -> false <$ do
logInfo "Authentication failed. Retrying in 5 seconds..."
liftAff $ delay $ 5.0 * 1000.0 # Milliseconds
_ -> true <$ do
put $ DidAuthenticate ownId
logInfo "Next authentication renewal scheduled for one hour from now"
liftAff $ delay $ 60.0 * 60.0 * 1000.0 # Milliseconds -- hourly
forks and channels
Channels allow injecting events from arbitrary I/O
data Action
= HangUpRequest
| DidReceiveIncomingCall
- The events generated can be of any type
- The attached Saga takes "Either outerInput innerInput" as input
forever $ take case _ of
Right HubIncomingCall fromUser ->
...
put DidReceiveIncomingCall -- we still put `Action`
Left HangUpRequest ->
...
data HubEvent
= HubIncomingCall Username
incomingCallsFlow :: ∀ env state. Saga' env state Action Action Unit
incomingCallsFlow = do
void $ channel
(\emit -> do
Hub.onIncomingCall \{ fromUser } ->
emit HubIncomingCall fromUser
) do
Example:
where to from here?
- Explore making Saga a Monad Transformer
- Add "race" combinator
- Add Parallel instance for Sagas?
- Introduce idea of finalizers to clean up resources upon error / cancellation or termination
I am still exploring ideas and seeing what's possible
Managing I/O in Purescript apps
By felixschl
Managing I/O in Purescript apps
FP Auckland Meetup
- 1,393