DEV Community

Zelenya
Zelenya

Posted on

PureScript for Scala developers

This is mostly based on Haskell for Scala developers. If you’ve seen that one already, your most interesting sections are Row Polymorphism, Aff and Effect, and Tooling.

Once again, we’ll cover the main differences, similarities, major gotchas, and most useful resources. Consider this a rough map.

Basic syntax and attitude

Driving on the wrong side of the road

You have to get used to reading chains of operations “in the opposite direction”. Take this Scala code:

def foo(err: Error) = err.extractErrorMessage().asLeft.some
Enter fullscreen mode Exit fullscreen mode

There are multiple ways to translate it to PureScript; the most straightforward:

-- read from right to left
foo err = Just (Left (message err))
Enter fullscreen mode Exit fullscreen mode

Notice the order of functions and how we call functions (e.g., no parentheses after extractErrorMessage). We can avoid even more parentheses by using the function application operator (f $ x is the same as f x):

-- read from right to left
foo err = Just $ Left $ message err
Enter fullscreen mode Exit fullscreen mode

And we can avoid the rest of the plumbing by using function composition:

-- read from right to left
foo = Just <<< Left <<< message
Enter fullscreen mode Exit fullscreen mode

In the wild, you will see all of these styles as well as their mix.

Function signatures

Did you notice that functions had no type signatures?

💡 PureScript is pretty good at inferring types. But some error messages aren’t newcomer-friendly; the more explicit types you add — the better the error messages.

foo :: forall a. Error -> Maybe (Either String a)
foo err = Just $ Left $ message err
Enter fullscreen mode Exit fullscreen mode

Note the explicit universal quantifier, forall (we’ll talk about this later).

The type signature goes above the function definition. It might feel awkward to connect a name to a type, but on the other hand, it allows you to focus on types (while ignoring the names).

bar :: User -> Maybe Password -> Either Error Credentials
bar user password = unsafeCrashWith "Not implemented"
Enter fullscreen mode Exit fullscreen mode

💡 The unsafeCrashWith function is a cousin of ???.

Common error. Could not match type

Because in PureScript, we use parentheses differently from the way we do in Scala, the most common newcomer error is forgetting or misusing them. For example:

good = foo user (Just password)

oops = foo user Just password
                ^^^^

  Could not match type
    Function t0
  with type
    Maybe
  while trying to match type t0 -> Maybe t0
    with type Maybe Password
  while checking that expression Just
    has type Maybe Password
  in value declaration x
  where t0 is an unknown type
Enter fullscreen mode Exit fullscreen mode

Technically, the compilation error is good: the foo takes a value with type Maybe Password, but we passed Just (which is a constructor, aka a function that takes a value of any type and returns Maybe of that type). However, in practice, it’s not that good — the actual error is that the programmer forgot parentheses and now passes Just and password separately (three arguments instead of two).

Names don’t matter?

Another slightly different but related thing is about common function names and operators.

There is map, but it’s more common to use operators: <$> and <#>.

x ∷ Array Int
x = (_ + 1) <$> [ 1, 2, 3 ]

x ∷ Array Int
x = [ 1, 2, 3 ] <#> (_ + 1)
Enter fullscreen mode Exit fullscreen mode

There’s no flatMap, there bind and >>= (bind operator), and in general PureScript codebases (and developers) are more tolerable towards operators:

fetchUser someUserId >>= case _ of
  Right user -> withDiscount <$> findSubscription user.subscriptionId
  Left _ -> pure defaultSubscription
Enter fullscreen mode Exit fullscreen mode

traverse is still traverse, and everything else you can pick up as you go.

Indentation

Unless you embraced Scala 3 and brace-less syntax, after years of curly brackets, it’s going to be hard to adjust to the indentation-based syntax, and it’s going to feel unnatural.

example  UserId  Array SubscriptionId  Effect JSX
example userId subscriptions = do
  user <- fetchUser userId
  userInfo <- case user of
    Right u -> pure $ renderUser u
    Left e -> showError e *> mempty
  let
    hasActive _ = true
    subscriptionInfo =
      if hasActive subscriptions then renderSubscriptions subscriptions
      else mempty
  pure $ renderBox [ userInfo, subscriptionInfo ]
Enter fullscreen mode Exit fullscreen mode

⌛ Effect is like IO, we’ll cover it later.

The minimal thing I can recommend here is to use a formatter, split things into smaller chunks, and when you are not sure, just push things left and right 😅

Basic concepts and fp

Function composition and Currying

In PureScript (like in Haskell), function composition and currying are first-class citizens. Both are powerful enough to make the code tidier and more elegant, as well as the opposite. You certainly have to practice to get better at writing and reading.

If you find some code confusing (or you wrote some code that the compiler doesn’t accept), try rewriting it: make it more verbose, introduce intermediate variables, add explicit types, and so on. It’s ok. It’s not a one-liner competition.

fetchUser someUserId >>= subscription
  where
  subscription (Right user) = userSubscription user
  subscription (Left _) = pure defaultSubscription

  userSubscription :: User -> Effect Subscription
  userSubscription user = withDiscount <$> findSubscription user.subscriptionId
Enter fullscreen mode Exit fullscreen mode

💡 where declarations are a convenient way to scope things within a function.

Also, if it feels like the number of arguments is getting out of control (or you miss having values and types right next to each other), try introducing records.

userInfo :: UserId -> SubscriptionId -> BundleServiceUrl -> Cache -> IO UserInfo
userInfo u s b c = unsafeCrashWith "Not implemented" 
Enter fullscreen mode Exit fullscreen mode
type Config = { api :: BundleServiceUrl, cache :: Cache }
type RequestIds = { userId :: UserId, subscriptionId :: SubscriptionId }

userInfo2 :: Config -> RequestIds -> Effect User
userInfo2 c { userId, subscriptionId } = unsafeCrashWith "Not implemented"
Enter fullscreen mode Exit fullscreen mode

Note how we pattern match in the case of RequestIds, we’ll touch on records later.

Purity

When writing PureScript, you can not just use a quick var or a temporary println. Depending on your experience and workflows, it can be or not be a big deal.

You can start by getting familiar with Debug (spy):

userSubscription user = 
    withDiscount <$> findSubscription (spy "test" user.subscriptionId)
Enter fullscreen mode Exit fullscreen mode

💡 See Debugging without a “real” debugger.

Modules, Imports, and Exports

There are no classes and objects.

You might quickly run into a problem with naming conflicts:

import Data.String
import Data.List 

foo :: Int
foo = length "abc"
--    ^^^^^^
-- Conflicting definitions are in scope for value length from the following modules:
--   Data.List
--   Data.String
Enter fullscreen mode Exit fullscreen mode

You have to get into a habit of using qualified imports:

import Data.String as S -- stylistic choice
import Data.List as List

foo :: Int
foo = S.length "abc"
Enter fullscreen mode Exit fullscreen mode

Some libraries suggest the users to qualify their imports; some people prefer to qualify all their imports, but, at the end of the day, it’s a personal/team choice. Start using qualified imports for collections (containers) and strings, and see how it goes.


There are no access modifiers (no private or public keywords). You can either export everything from the module (in the module declaration on the top):

module Test where
Enter fullscreen mode Exit fullscreen mode

Or export (aka make public) only specific functions, data types, type classes, etc.:

module Test
  ( publicMethod
  , PublicConstructor
  , class Example
  ) where
Enter fullscreen mode Exit fullscreen mode

Types

Product Types

PureScript records correspond to JavaScript objects. Here is a function that takes some user model and bumps their score:

bumpCounter
  :: { userId :: String, counter :: Int }
  -> { userId :: String, counter :: Int }
bumpCounter user = user { counter = user.counter + 1 }

bumpCounter { userId: "test", counter: 10 }
-- { counter: 11, userId: "test" }
Enter fullscreen mode Exit fullscreen mode

We can make it tidier by introducing a type alias:

type UserScore = { userId :: String, counter :: Int }

bumpCounter :: UserScore -> UserScore
bumpCounter user = user { counter = user.counter + 1 }
Enter fullscreen mode Exit fullscreen mode

We can also use various combinators to work with records:

bumpCounter :: UserScore -> UserScore
bumpCounter = Record.modify (Proxy :: _ "counter") (_ + 1)
Enter fullscreen mode Exit fullscreen mode

In this case, the code is a bit longer, but imagine chaining and using multiple operations like unions, deleting and renaming fields, etc.

🍪 There is an upcoming alternative syntax for this — using @ (Visible type applications) instead of Proxy …

Sum types

Sum types (aka Tagged Unions) should be too surprising. Those can be deconstructed through pattern matching:

data Role = Member UserId | Admin

hasRights :: Role -> Boolean
hasRights role = case role of
  Member (UserId "special") -> true
  Admin -> true
  Member _ -> false
Enter fullscreen mode Exit fullscreen mode

Newtypes

Newtypes are introduced with the newtype keyword:

newtype UserId = UserId String
Enter fullscreen mode Exit fullscreen mode

Row Types

It’s one of the coolest PS features but also one of the main sources of confusion and errors for newcomers.

UserScore is a record and we can make a value of the type UserScore.

type UserScore = { userId :: String, counter :: Int }

value :: UserScore
value = { userId: "test", counter: 10 }
Enter fullscreen mode Exit fullscreen mode

It has two fields: userId of type String and counter of type Int.

On the other hand, those are rows (note the parentheses):

type ClosedRow = ( userId :: String, counter :: Int)

type OpenRow r = ( userId :: String, counter :: Int | r )
Enter fullscreen mode Exit fullscreen mode

Let’s not worry about the difference right now. A row of types represents an unordered collection of named types. Rows are not of kind Type. Rows cannot exist as a value.

🌯 Kinds are types of types. It’s what distinguishes List from List[Int] (among other things). You can’t create a value of just List of just Option, but you can create values of List[Int], Option[String], and so on.

Row types unlock many typelevel operations. We won’t go into details here.

The simplest application of rows:

type UserScore = (userId :: String, counter :: Int)

type UserDTO = { createdAt :: DateTime | UserScore }

type UserResponse = 
  { age :: Int, subscriptions :: Array SubscriptionId | UserScore }

enrichUser :: UserDTO -> Effect UserResponse
enrichUser { userId, counter, createdAt } = unsafeCrashWith "???"
Enter fullscreen mode Exit fullscreen mode

Common error. Could not match kind Type with kind Row Type

We can add rows to get a row, pass row types, return them, whatever. Because it’s new for many, there is this one typelevel error that needs attention:

Could not match kind

  Type

with kind

  Row Type
Enter fullscreen mode Exit fullscreen mode

It means, you passed a type (usually, representing a record) where the row type was expected.

For example, there is this type alias for declaring components that that takes a row:

type FFIComponent props = ...
Enter fullscreen mode Exit fullscreen mode

We can pass it the props that we care about as a row type:

type ButtonProps =
  ( asChild :: Boolean
  )

buttonExample :: FFIComponent ButtonProps
Enter fullscreen mode Exit fullscreen mode

But if we pass a record by accident, we get that error:

type ButtonPropsOops =
  { asChild :: Boolean
  }

buttonExample :: FFIComponent ButtonPropsOops
Enter fullscreen mode Exit fullscreen mode
Could not match kind

  Type

with kind

  Row Type
Enter fullscreen mode Exit fullscreen mode

🌯 Once again remind yourself about kinds. If you’ve conquered F[_], you can conquer row types.

Polymorphism

What in the Java world is called generics, in the PureScript world is called parametric polymorphism.

-- a is a type parameter
filter :: forall a . (a -> Boolean) -> [a] -> [a]
Enter fullscreen mode Exit fullscreen mode

Polymorphic functions require an explicit forall (to declare type variables before using them). Other than that, in general practice, it’s not that different.

Row Polymorphism

We can rewrite the bumpCounter function to make it more polymorphic (more generic):

bumpCounter
  :: forall r
   . { userId :: String, counter :: Int | r }
  -> { userId :: String, counter :: Int | r }
bumpCounter user = user { counter = user.counter + 1 }
Enter fullscreen mode Exit fullscreen mode

r is a row of types (zero or more of some other rows that we don't care about). In other words, the function takes (and returns) any record that has at least one field userId with type String, counter with type Int, and whatever else.

To make it more interesting, we can change the type of the counter

processCounter
  :: forall r
   . { userId :: String, counter :: Int | r }
  -> { userId :: String, counter :: String | r }
processCounter user = user { counter = show (user.counter + 1) }
Enter fullscreen mode Exit fullscreen mode

In either case, we can pass any record that fits the shape, including existing UserScore:

processCounter { userId: "x", counter: 10 }
-- { counter: "11", userId: "y" }

processCounter { userId: "y", counter: 10, payments: [ 1, 2, 3 ], whatever: 2 }
-- { counter: "11", payments: [1,2,3], userId: "y", whatever: 2 }
Enter fullscreen mode Exit fullscreen mode

Type classes

Unlike Scala, PureScript has built-in type classes — there's (guaranteed to be) at most one instance of a type class per type.

Good news: No need to worry about imports!

There are other things you’ll have to worry about at some point, but don’t worry about them right now. You can start by declaring the type class instances next to the type class (in the same module) or next to the data.

Sometimes, when you need a new (custom) instance, you can introduce a newtype:

-- User is declared in some other module

newtype InvalidUser = InvalidUser User 

instance Arbitrary InvalidUser where
  arbitrary = do
    userId <- UserId <$> arbitrary
    subscriptionId <- SubscriptionId <$> arbitrary
    pure $ InvalidUser { userId, subscriptionId }
Enter fullscreen mode Exit fullscreen mode

And remember that you can get a lot for free with deriving.

Deriving

In PureScript, you can derive quite a lot.

newtype JwtAccessToken = JwtAccessToken String

derive instance Newtype JwtAccessToken _
derive newtype instance Eq JwtAccessToken
derive newtype instance Show JwtAccessToken
derive newtype instance ReadForeign JwtAccessToken
derive newtype instance WriteForeign JwtAccessToken
Enter fullscreen mode Exit fullscreen mode

Meta Programming

The most common way to generate boilerplate in PureScript is generic programming. Not java generics. Even more generic generics!

💡 That’s one of the reasons not to refer to parametric polymorphism as just (java) generic types.

Generic programming

Generic programming comes in different shapes and forms. PureScript has a built-in Generic class is the purescript-prelude library. The most common usage is deriving show instance for sum types:

import Data.Generic.Rep (class Generic)
import Data.Show.Generic (genericShow)

data Role = Member UserId | Admin

derive instance Generic Role _
instance Show Role where
  show x = genericShow x
Enter fullscreen mode Exit fullscreen mode

🌯 Generics are kinda like shapeless.

Best practices (and concepts)

Aff and Effect

Instead of one IO data type, there are two, Aff and Effect.

💡 Both can be used with the do notation (for comprehension), shouldn’t be surprising.

Aff is for asynchronous computations, Effect is for synchronous, and they can’t be mixed. If you know of function coloring, you should know why; and if not — let’s look at examples.

💡 A reminder: JavaScript is single-threaded and achieves concurrency via asynchronous operations and event loop.

So, for example, api call has to be asynchronous. We don’t want to block everything waiting for response:

getUserProducts :: Config -> UserId -> Aff Product
getUserProducts { apiUrl } (UserId id) = do
  -- Aff
  { status, json } <- fetch (apiUrl <> "/users/" <> id) { method: GET }
  case status of
    -- Aff
    200 -> fromJSON json
    -- MonadThrow
    other -> throwError $ error $ "Unexpected response status: " <> show other
Enter fullscreen mode Exit fullscreen mode

Button click, on the other hand, should respond (have an effect) right away:

{ variant :: String
, size :: String
, onClick :: SyntheticEvent -> Effect Unit 
} 
Enter fullscreen mode Exit fullscreen mode

We can’t mix the two, but we can convert one to another. It’s easy to make a synchronous computation (Effect) asynchronous (Aff). For example, we can use liftEffect to add something to our async call:

-- This is for illustration purposess only
syncLog :: String  Effect Unit
syncLog = log

getUserProducts :: Config -> UserId -> Aff Product
getUserProducts { apiUrl } (UserId id) = do
  { status, json } <- fetch (apiUrl <> "/users/" <> id) { method: GET }
  -- This is where we convert
  liftEffect $ syncLog "This is sync"
  case status of
    200 -> fromJSON json
    other -> throwError $ error $ "Unexpected response status: " <> show other
Enter fullscreen mode Exit fullscreen mode

And what if we want to make an api call on a button click? The easiest thing to do is to fork the computation:

setUserProfile :: Aff Unit
setUserProfile = do
  result <- try $ apiClient.getUserProducts userId -- Aff
  liftEffect case result of
    Right user -> setUserProfile user -- Effect
    Left error -> showError error     -- Effect
Enter fullscreen mode Exit fullscreen mode
button
  { variant: "ghost"
  , size: "md"
  , onClick: handler preventDefault $ launchAff_ setUserProfile *> log "Clicked"
  }
Enter fullscreen mode Exit fullscreen mode

On click, it’s going to asynchronously make an api call (which then depending on result either sets some user info or shows an error) but also (concurrently) log a message to the console. No blocking. Note that we throw away the fiber handle — in this case, we don’t care about the result, only side effects.

The other option is to use the useAff hook or useAffReducer to deal with async stuff.

setUserId :: UserId -> Effect
Enter fullscreen mode Exit fullscreen mode
useAff userId do
  result <- try $ apiClient.getUserProducts userId
  liftEffect case result of
    Right sub -> setUserProfile user
    Left error -> showError error
  pure unit
Enter fullscreen mode Exit fullscreen mode
button
  { variant: "ghost"
  , size: "md"
  , onClick: handler preventDefault $ setUserId someUserId *> log "Clicked"
  }
Enter fullscreen mode Exit fullscreen mode

Abstractions and type classes

It’s important to know your type classes. Those are a must: Functor, Applicative, Monad, Semigroup, Monoid, Foldable, Traversable, and Alternative. If the library has a functionality that can be provided by one of those (e.g., smooshing or mapping things), it’s likely that it’s going to be provided and won’t be very documented.

Addition and multiplication come via Semirings; boolean operation come via HeytingAlgebra.

Failure handling

The situation is not much better than in Scala.

  • Option is called Maybe.
  • Error is the the type of JavaScript errors that lives in Effect.Exception (cousins of Throwable). You can throws those via throwException ****into ~~IO~~, I mean Effect
  • There is also MonadError that, for instance, has try and let’s us catch errors in Effect and Aff.

Organizing code

There are options for all:

  • It’s common and convenient to use plain data types and pass things around via records.
  • There are mtl classes (see the transformers library).
  • There is yoga-omOm data type (Om ctx errs a) has a context, potential errors, and the value of the computation.

Tooling

You know how you have Scala and Java code, than use sbt (or some other build tool), and it all becomes one bytecode mess that you run or deploy?

In PureScript world, we can’t get away with using just one tool.

We use spago (a PureScript package manager and a build tool) to run and test pure PureScript projects, as well as turn PS code into JS code. For example, we can run spago test to run our PS tests.

⚠️ Note that, there is legacy spago and spago@next.

But we can’t get far with PureScript alone in the modern web. So.

We use dependency/package managers, such as npm or yarn, to install JS packages (both globally and locally). For example, we can install and use react in our PS project.

We also need a bundler — another build tool that let’s us bundle everything together (into a single executable file or few files), optimize JavaScript, and produce production files when we are ready to ship. There are multiple options here; the most popular are esbuilt, webpack, and parcel (see spago tutorial).

💡 Note that spago has a bundle command to bundle the project. Maybe that’s enough for your use case.


Bad news: this is just a tip of the iceberg of tools.

Good news: no need to worry, usually all of it is going to be hidden from you behind npm or yarn scripts. Usually, you can get away with limiting your vocabulary to npm install, npm start, npm deploy, or something along those lines.

Editor

You can use Intellij with PureScript plugin or any other editors via purs ide (an IDE server that comes with compiler) or PureScript language server (built on top of IDE server).

💡 For details, see the dedicated documentation section on Editor support.

Don’t expect java-level IDE support.

Libraries

Searching for functions

If you’re looking for some function or some data type, you can still use dot completion on a module, but if you don’t know where to look, try pursuit.

You can also use typed holes, to get compiler suggestions (it also works in the editor).

foo = ?custom someUserId >>= subscription
Enter fullscreen mode Exit fullscreen mode
Hole 'custom' has the inferred type

  UserId
  -> Effect
       (Either t0
          { subscriptionId :: SubscriptionId
          , userId :: UserId
          }
       )

You could substitute the hole with one of these values:

  TestTypes.fetchUser         :: UserId
                                 -> Effect
                                      (Either Error
                                         { subscriptionId :: SubscriptionId
                                         , userId :: UserId
                                         }
                                      )

Enter fullscreen mode Exit fullscreen mode

You can get help with types as well:

userSubscription (user :: ?What)
Enter fullscreen mode Exit fullscreen mode
Hole 'What' has the inferred type

    { subscriptionId :: SubscriptionId
    , userId :: UserId
    }

  in the following context:

    subscription :: Either Error
                      { subscriptionId :: SubscriptionId
                      , userId :: UserId
                      }
                    -> Effect Subscription
    user :: { subscriptionId :: SubscriptionId
            , userId :: UserId
            }
    userSubscription :: { subscriptionId :: SubscriptionId
                        , userId :: UserId
                        }
                        -> Effect Subscription
Enter fullscreen mode Exit fullscreen mode

Standard library

Standard library (std module) is called Prelude (the Prelude module comes via the purescript-prelude library). It has to be explicitly imported.

import Prelude
Enter fullscreen mode Exit fullscreen mode

This, for example, imports common type class methods, such as <$> and pure.

Searching for libraries and FFI

If you’re looking for a library (a package), you can still poke around pursuit. But if you can’t find anything, there is always a see of JS libraries that can be used from PS. We do this via The Foreign Function Interface (or FFI, for short).

We’ve demonstrated this in the PureScript + Shadcn video.

Searching and managing dependency versions

You don’t have to search for specific library versions compatible with other libraries. There are lists of package versions that build together, called a package set.

Reproducible builds are one of the core principles of spago.

Books and other resources

Top comments (0)