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

Made with Slides.com