RealWorld Halogen ready for technical review

Over the past two months I’ve written an implementation of the RealWorld project for Halogen. You can see all 3,000 lines of the completed project here (pending some tests):

It’s the only project I’m aware of that showcases how to build a non-trivial application in PureScript. It follows the ReaderT pattern and comes with a long-form guide (still in progress) explaining the ideas and principles behind the implementation.

I am nearly ready to submit the project to the official RealWorld project by Thinkster, a 20,000-star repository that lets you compare and contrast the same application implemented in different frameworks. It’s a fantastic opportunity to showcase PureScript and Halogen to a wide audience.

Before I do that, however, I would love to make sure I really am showcasing the best of PureScript!

Call for technical review (Halogen users)

I’m not quite ready to share this project widely or submit it to the Thinkster repository. I would like to get a few other folks’ eyes on the project to help call out places where:

  • I have written something clunky or verbose, which could be done better
  • I have written something unnecessarily complex, which might scare off beginner-intermediates
  • I have done something that typically wouldn’t be done in the real world, OR I have failed to do something that would be done in the real world.
  • I have done something incorrect and diverged from the RealWorld spec

I understand that technical / code reviews are a big time investment. But even a quick glance over the project might lead to an issue jumping out at you. If you can spare 15 minutes to read through some of the key parts of the application and see what you think, it could lead to meaningful improvements to this resource.

Here’s the link:
RealWorld Halogen

Next steps

I plan to release the project officially in the new year (hopefully Halogen 5 doesn’t come out before I do!). The guide will be updated and completed in that time, and so by mid-January we should have a real world demonstration application ready to go for Halogen!

12 Likes

I am only just starting out with Purescript and Halogen, so I can’t really comment too much about the quality of the Purescript or how idiomatic it is.

I am implementing two production projects in Halogen at the minute and have been using this project as a guide to structuring them. It has been invaluable! I found the code to be easy to follow and work out what was going on. It was easy to adapt the structure to my projects. I haven’t come across anything yet that isn’t really covered.

You know about the documentation needing updating… so I don’t need to mention that! Little things like the documentation for Logging is different to the actual implementation confused me at first.

So from a beginners perspective I would say it is very well put together. Thankyou so much for doing it - I can honestly say I would have been lost without it!

3 Likes

Boy this is a pretty old post, but maybe this is still relevant :).

So recently I started reviewing learning material especially now that Halogen has pretty bad-ass documentation.

TL;DR

I think this app is understandable for an intermediate maybe, in my view at least. I mean I clearly would imagine that an intermediate is at the level where he knows about bifunctors and a bit more advanced topics that aren’t necessarily in the Purescript book.

From a beginner (as in someone starting with FP that went through the book) perspective I’m willing to bet they will have a hard time even understanding the main function. I think this is relatively easy to solve though.

    readToken >>= traverse_ \token -> do
      let requestOptions = { endpoint: User, method: Get }
      launchAff_ do
        res <- request $ defaultRequest baseUrl (Just token) requestOptions

        let
          user :: Either String _
          user = case res of
            Left e ->
              Left (printError e)
            Right v -> lmap printJsonDecodeError do
              u <- Codec.decode (CAR.object "User" { user: CA.json }) v.body
              CA.decode Profile.profileCodec u.user

        liftEffect (Ref.write (hush user) currentUser)

So this part isn’t that well explained I think, leaving the beginner with a bunch of questions:

  1. What’s the Either String _ syntax?
  2. Why are we using Either here and track errors if at the end we just discard them anyway (via hush)?
  3. In case we want to do something about these errors, especially the network errors, how would one tackle that? Say by logging them at least.
  4. Why Codec.decode & CA.decode when CA just reexports Codec.decode?
  5. defaultRequest is a custom implementation, it isn’t defaultRequest from Affjax so one wonders what the heck is that all about, that is if we don’t know about this custom implementation. I mean not many people tend to first read the import statements, I don’t think so :slight_smile:
  6. What’s lmap?! And again, why do we even care to convert these errors to String if we don’t even use them?

These are a few questions I could raise as a beginner trying to understand a real-world example. I think most of them are easy to solve, I’m a bit verbose just to be as clear as I can. I’ll be posting my feedback from a beginner perspective as I go.

Thanks for the demo and guide!

1 Like

Here’s my initial guess behind why the Either stuff still converts things to Strings even though it ultimately gets discarded. Ideally, one would write user in the Either monad, so that all Right paths are done:

user = do
  v <- res
  u <- Codec.decode (CAR.object ....) v.body
  pure $ CA.decode Profile.profileCodec u.user

However, the Either monad only works if the left parameter (i.e. Either left right) is the same throughout the entire do block. In this case, it’s not. res is Either Error Json. Codec.decode is Either JsonDecodeError Result. Since Error is not JsonDecodeError, do notation will not work. To make this clearer, it seems Thomas used case statements to get around this. Otherwise, we would need to use flip lmap to change the left parameter to the same type.

let
 flippedlmap :: forall m a b c. m a c -> (a -> b) -> m b c
 flippedlmap = flip lmap

 flippedlmapEither :: forall a b c. Either a c -> (a -> b) -> Either b c
 flippedlmapEither m f = case m of
   Left l -> Left (f l)
   right -> right

The easiest type to share among them is String. Thus, we would rewrite it to…

user = do
  v <- res `flip lmap` \e -> printError e
  u <- (Codec.decode (CAR.object ....) v.body) `flip lmap` \e -> 
    printJsonDecodeError e
  pure $ CA.decode Profile.profileCodec u.user

Now that the “same left type parameter” of the Either monad requirement has been dropped, we can now drop the parameter via hush and call liftEffect (Ref.write (hush user) currentUser).

The other way to get around this ‘left parameter’ problem is to convert each Either into the Maybe monad before we ever use bind to output the computation’s results:

user :: Maybe _
user = do
  v <- hush res
  u <- hush $ Codec.decode (...) v.body
  hush $ CA.decode Profile.profileCodec u.user

liftEffect (Ref.write user currentUser)

So, which of the three ways is easiest to understand?

  1. Thomas’ original method
  2. the flip lmap followed by hush method?
  3. the multi-hush Maybe monad method?

From my point of view the current implementation is good, it just needs further explanation a bit and possibly extended to include logging of potential network errors.

As you can see I’m taking this super slow, but I’m making progress! With Purescript and all the good stuff that is :slight_smile:

Onto RealWorld Halogen. Would there be any downside in doing something like this for getting the token instead of using traverse_?

diff --git a/src/Main.purs b/src/Main.purs
index 033eb86..3f9f65e 100644
--- a/src/Main.purs
+++ b/src/Main.purs
@@ -93,10 +93,10 @@ main = HA.runHalogenAff do
     -- Note: this is quite a verbose request because it uses none of our helper functions. They're
     -- designed to be run in `AppM` (we're in `Effect`). This is the only request run outside `AppM`,
     -- so it's OK to be a little verbose.
-    readToken >>= traverse_ \token -> do
+    readToken >>= \maybeToken -> do
       let requestOptions = { endpoint: User, method: Get }
       launchAff_ do
-        res <- request $ defaultRequest baseUrl (Just token) requestOptions
+        res <- request $ defaultRequest baseUrl maybeToken requestOptions
 
         let
           user :: Either String _

Because defaultRequest already expects the token wrapped in Maybe and this just seemed like an unnecessary step.


By the way Thomas, I just discovered Vlad’s channel and your interview on there. Really enjoyed your approach, very surprised to hear that Haskell/Purescript was your gateway to programming not something like the all-around-trusted imperative JavaScript :slight_smile:

1 Like

@razcore-rad I believe I implemented it that way because there’s no reason to perform the request if we know there is no token. Switching to readToken >>= \maybeToken -> will always perform the request, perhaps with or without a token.

very surprised to hear that Haskell/Purescript was your gateway to programming not something like the all-around-trusted imperative JavaScript

Well, the all-around-trusted JavaScript was my first experience programming, but I had such a hard time getting anything to work that I almost gave up until I found Haskell & PureScript!

1 Like

Yeah, that makes sense, skipping the function call. Thanks :slight_smile:

@razcore-rad Check out logHush and debugHush at the bottom of Capability.LogMessages. These look helpful for logging the Left error and then giving you a simplified Maybe result type to work with like the pure hush does.

-- | Hush a monadic action by logging the error, leaving it open why the error is being logged
logHush :: forall m a. LogMessages m => Now m => LogReason -> m (Either String a) -> m (Maybe a)
logHush reason action =
  action >>= case _ of
    Left e -> case reason of
      Debug -> logDebug e *> pure Nothing
      Info -> logInfo e *> pure Nothing
      Warn -> logWarn e *> pure Nothing
      Error -> logError e *> pure Nothing
    Right v -> pure $ Just v

-- | Hush a monadic action by logging the error in debug mode
debugHush :: forall m a. LogMessages m => Now m => m (Either String a) -> m (Maybe a)
debugHush = logHush Debug

Outside of where they are defined, I can’t actually see them used throughout the rest of the Realworld Halogen codebase though.

1 Like

While we have the thread necro’d, I have a question of my own about the LogMessages AppM instance (here) pasted below:

-- | Next up: logging. Ideally we'd use a logging service, but for the time being, we'll just log
-- | to the console. We'll rely on our global environment to decide whether to log all messages
-- | (`Dev`) or just important messages (`Prod`).
instance logMessagesAppM :: LogMessages AppM where
  logMessage log = do
    env <- ask
    liftEffect case env.logLevel, Log.reason log of
      Prod, Log.Debug -> pure unit
      _, _ -> Console.log $ Log.message log

From the above, all constructors of LogReason use Console.log to write them out. Is there a reason not to use Console.error for Error, Console.warn for Warn, etc.?

Or is this a case of the implementation just being an example and it doesn’t really matter because in practice — like the comment above it says — you’d be using a proper logging service anyway?

I think this is just an oversight. It doesn’t really matter in this example, but this probably should use Console.error for errors, Console.warn for warnings, and so on. I’d welcome a PR fixing that!