3

Type-Driven Development with PureScript

 3 years ago
source link: https://blog.oyanglul.us/purescript/type-driven-development-with-purescript/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

User should be able to view a list of Todos

To model the problem accurately, we need to know what behavior of Todo app would expected.

Assuming we already have our restful back end developed.

If you visit: https://jsonplaceholder.typicode.com/todos/

It returns data in such JSON format:

[
  {
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
  },
  {
    "userId": 1,
    "id": 2,
    "title": "quis ut nam facilis et officia qui",
    "completed": false
  }
]

Ok, so this will clearly be the Data Type we need.

module Data.Todo where

type Todo = {
  userId:: Int,
  id:: Int,
  title:: String,
  completed:: Boolean
}

type Todos = Array Todo

To initiating the behavior, the data need to be load from remote server at the first place.

Since all JavaScript request will be async, Effect.Aff would be the best type to describe such behavior. I supposed we need to specify a Path so that we know where to load the data from.

module Behavior.Load where
import Effect.Aff
import Data.Todo
import Prelude

type Path = String
load :: Path -> Aff (Array Todo)

Here is the type we need that can describe our behavior very accurate:

providing the Path, we should able to get an Asynchronous Effect that eventually has value of Array of Todo

Now we have a decent type, let us "Define" it, by pressing C-c C-a

Define

module Behavior.Load where
import Effect.Aff
import Data.Todo
import Prelude

type Path = String
load :: Path -> Aff (Array Todo)
load _ = ?load

Oh, compiler generate an function definition for us, let us hover the cursor on that question mark ?load thing

  Hole 'load' has the inferred type

    Aff
      (Array
         { completed :: Boolean
         , id :: Int
         , title :: String
         , userId :: Int
         }
      )

  You could substitute the hole with one of these values:

    Control.Plus.empty  :: forall a f. Plus f => f a
    Data.Monoid.mempty  :: forall m. Monoid m => m
    Effect.Aff.never    :: forall a. Aff a


in value declaration load
 [HoleInferredType]

Mmm…very clear, compiler is guessing the implementation could be one of:

  • Control.Plus.empty
  • Data.Monoid.mempty
  • Effect.Aff.never

But which one should I use?

Let's try all of them, replace ?load with empty

module Behavior.Load where
import Effect.Aff
import Data.Todo
import Prelude

type Path = String
load :: Path -> Aff (Array Todo)
load _ = empty

C-c C-i editor will ask you which Module to import from? Tell it Control.Plus

module Behavior.Load where

import Data.Todo
import Effect.Aff
import Prelude

import Control.Plus (empty)

type Path = String
load :: Path -> Aff (Array Todo)
load _ = empty

Oh my… it compiled. We just did it.

TODO But Why?

Why Control.Plus.empty works?

Actually all of them work.

Refine

So, if we run it, what will happen?

> runAff_ (\x -> log (show x)) $ load "asdf"
(Left Error: Always fails
    at Object.exports.error (/home/jcouyang/Documents/blog/org/purescript/type-driven-development-with-purescript/.psci_modules/node_modules/Effect.Exception/foreign.js:8:10)
    at Object.<anonymous> (/home/jcouyang/Documents/blog/org/purescript/type-driven-development-with-purescript/.psci_modules/node_modules/Effect.Aff/index.js:417:73)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Module.require (internal/modules/cjs/loader.js:690:17)
    at require (internal/modules/cjs/helpers.js:25:18)
    at Object.<anonymous> (/home/jcouyang/Documents/blog/org/purescript/type-driven-development-with-purescript/.psci_modules/node_modules/Behavior.Load/index.js:3:18))
unit

Ok, it resolve as Left Error

Seems we did not finish yet, we probably should be more specific about what should we do in defination

Maybe?

load path = ajax path

There are lot of implementation of making Ajax call for PureScript like Affjax, but I like to show how easy to make your own one by PureScript's FFI.

A little bit JavaScript to call window.fetch, to make it FFI, we need to name it the same Behavior.Load.js

function get(url) {
  return function(onError, onSuccess) {  
    window.fetch(url).then(function(res){
      return res.text()
    })
      .then(onSuccess)
      .catch(onError)
    return function(cancelError, cancelerError, cancelerSuccess) {
      cancelerSuccess()
    };
  }
}
exports._get = get

Now you can foreign import the get function from JavaScript

import Effect.Aff.Compat (EffectFnAff(..))

foreign import _get :: Path -> EffectFnAff String

So the _get function can take a Path and return EffectFnAff String.

But String is not he value we need, what we need is Todos.

Then another layer of abstraction to provide us the domain type is needed.

Just call it ajaxGet for now.

import Data.Either (Either)
import Simple.JSON (class ReadForeign)

ajaxGet :: forall a. ReadForeign a => Path -> Aff (Either Error a)
ajaxGet _ = ?ajaxGet

Type of ajaxGet can read as "given type a which has instance of ReadForeign a, input a Path and it can return an Aff of Either Error a".

Define

C-c C-a compiler will define ajaxGet _ = ?ajaxGet

Move cursor to ?ajaxGet and…

  Hole 'ajaxGet' has the inferred type

    Aff (Either Error a0)

  You could substitute the hole with one of these values:

    Control.Plus.empty  :: forall a f. Plus f => f a
    Effect.Aff.never    :: forall a. Aff a


in value declaration ajaxGet

where a0 is a rigid type variable
        bound at (line 0, column 0 - line 0, column 0)
 [HoleInferredType]

Hmm, clearly we don't want an empty, look what we have currently

_get :: Path -> EffectFnAff String -- FFI
fromEffectFnAff :: forall a. EffectFnAff a -> Aff a -- from Effect.Aff.Compat
readJSON :: forall a. ReadForeign a => String -> Either MultipleErrors a -- from Simple.JSON

Refine

It's like solve puzzles, return type of _get match fromEffectFnAff input type. Let us we compose, see what we got

ajaxGet :: forall a. ReadForeign a => Path -> Aff (Either Error a)
ajaxGet path = ?toJSON $ fromEffectFnAff (_get path)

Move cursor to ?toJSON see what we need to put in here now.

Hole 'toJson' has the inferred type

  Aff String -> Aff (Either Error a0)

Great, we have

readJSON :: forall a. ReadForeign a => String -> Either MultipleErrors a

which is pretty similar though…

How can we get rid of the high kind Aff?

If we lift String -> Either Error a to Aff level, we should able to get Aff String -> Aff (Either Error a).

That is exactly <> does, put a <> around $ and it will lift the left hand side

ajaxGet :: forall a. ReadForeign a => Path -> Aff (Either Error a)
ajaxGet path = ?toJSON <$> fromEffectFnAff (_get path)

Now compiler says:

Hole 'toJson' has the inferred type

  String -> Either Error a0

Refine

So close, now just need Either MutipleErrors a -> Either Error a, isn't that exactly type signature of lmap?

ajaxGet path = (lmap ?adaptError <<< parseJSON )<$> fromEffectFnAff (_get path)
  where
    parseJSON :: String -> Either MultipleErrors a
    parseJSON = readJSON
Hole 'adaptError' has the inferred type

  NonEmptyList ForeignError -> Error

Seems to be a very easy function to implement, finally!

Define

ajaxGet path = (lmap adaptError <<< parseJSON )<$> fromEffectFnAff (_get path)
  where
    parseJSON :: String -> Either MultipleErrors a
    parseJSON = readJSON
    adaptError :: MultipleErrors -> Error
    adaptError = error <<< show

Without single line of test, and run time red-green. We just follow the compiler's hint, compose different pieces of type together and then form the type that just fit our domain problem. And the most amazing part is even without unit tested, I'm very confident that compiler already proven type is work, the code driven from type should be working fine too.

However, I'm not saying we should not write any unit test, the part FFI calling the JavaScript function can not be proven by compiler that it is working.

Now that we have ajaxGet, we can replace empty in load with the real ajax call function.

load :: Path -> Aff (Array Todo)
load path = do
  resp <- ?ajaxGetTodos path
  ?doSomethingAbout resp
Hole 'ajaxGetTodos' has the inferred type

  String -> Aff t0

Define

That is ajaxGet, let us put that in

load :: Path -> Aff (Array Todo)
load path = do
  resp <- ajaxGetTodos path
  ?doSomethingAbout resp
  where
    ajaxGetTodos :: Path -> Aff (Either Error (Array Todo))
    ajaxGetTodos = ajaxGet

Now what is ?doSomethingAbout

Hole 'doSomethingAbout' has the inferred type

  Either Error
    (Array
       { completed :: Boolean
       , id :: Int
       , title :: String
       , userId :: Int
       }
    )
  -> Aff
       (Array
          { completed :: Boolean
          , id :: Int
          , title :: String
          , userId :: Int
          }
       )

I think we need a liftEither :: forall a. Either Error a -> Aff a, let us define it

load :: Path -> Aff (Array Todo)
load path = do
    resp <- ajaxGetTodos path
    liftEither resp
    where
      ajaxGetTodos :: Path -> Aff (Either Error (Array Todo))
      ajaxGetTodos = ajaxGet
      liftEither :: forall a. Either Error a -> Aff a
      liftEither _ = ?liftEither

Define

C-c C-c on _, compiler will prompt you what type you what to split.

Tell it Either

liftEither :: forall a. Either Error a -> Aff a
liftEither (Left _) = ?liftEither
liftEither (Right _) = ?liftEither

Now it's all clear, ?liftEither is Aff a:

liftEither :: forall a. Either Error a -> Aff a
liftEither (Left e) = throwError e
liftEither (Right v) = pure v

All feature of load function is done since compiler is very happy about it. But, we never rich the Refine yet.

TODO Refine

One thing that is able to refine is liftEither, maybe this is not the best time to refine, since only one place is using it. But it seems like it should be a typeclass not just a scoped function. Because it looks very generic.

class MonadAff m <= MonadEither m where
  liftEither :: Either Error ~> m

instance monadEitherAff :: MonadEither Aff where
  liftEither (Left e) = throwError e
  liftEither (Right v) = pure v

Final Version

module Behavior.Load where

import Data.Todo
import Effect.Aff
import Prelude

import Data.Bifunctor (lmap)
import Data.Either (Either(..))
import Effect.Aff.Class (class MonadAff)
import Effect.Aff.Compat (EffectFnAff(..), fromEffectFnAff)
import Foreign (MultipleErrors)
import Simple.JSON (class ReadForeign, readJSON)

type Path = String
foreign import _get :: Path -> EffectFnAff String

ajaxGet :: forall a. ReadForeign a => Path -> Aff (Either Error a)
ajaxGet path = (lmap adaptError <<< parseJSON ) <$> fromEffectFnAff (_get path)
  where
    parseJSON :: String -> Either MultipleErrors a
    parseJSON = readJSON
    adaptError :: MultipleErrors -> Error
    adaptError = error <<< show

load :: Path -> Aff (Array Todo)
load path = do
    resp <- ajaxGetTodos path
    liftEither resp
    where
      ajaxGetTodos :: Path -> Aff (Either Error (Array Todo))
      ajaxGetTodos = ajaxGet

type State = {
             todos:: Todos
             }
reloadPage :: State -> Aff State
reloadPage _ = do
  entities <- load("https://jsonplaceholder.typicode.com/todos")
  pure {todos: entities}

class MonadAff m <= MonadEither m where
  liftEither :: Either Error ~> m

instance monadEitherAff :: MonadEither Aff where
  liftEither (Left e) = throwError e
  liftEither (Right v) = pure v

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK