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
There are multiple ways to translate it to PureScript; the most straightforward:
-- read from right to left
foo err = Just (Left (message err))
Notice the order of functions and how we call functions (e.g., no parentheses after message
). 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
And we can avoid the rest of the plumbing by using function composition:
-- read from right to left
foo = Just <<< Left <<< message
Good news: You can reverse all of it and read from left to right if you want:
-- read from left to right to right
foo = message >>> Left >>> Just
-- read from left to right to right
foo err = message err # Left # Just
Bad news: more syntax to get used to. In the wild, you will see all of these styles and 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
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"
💡 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 = bar user (Just password)
oops = bar 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
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)
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
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 ]
⌛
Effect
is likeIO
, 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
💡
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"
type Config = { api :: BundleServiceUrl, cache :: Cache }
type RequestIds = { userId :: UserId, subscriptionId :: SubscriptionId }
userInfo2 :: Config -> RequestIds -> Effect User
userInfo2 c { userId, subscriptionId } = unsafeCrashWith "Not implemented"
Note how we pattern match in the case of RequestIds
, we’ll touch on records later.
Purity
When writing PureScript, you cannot 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)
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
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"
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
Or export (aka make public) only specific functions, data types, type classes, etc.:
module Test
( publicMethod
, PublicConstructor
, class Example
) where
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" }
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 }
We can also use various combinators to work with records:
bumpCounter :: UserScore -> UserScore
bumpCounter = Record.modify (Proxy :: _ "counter") (_ + 1)
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 ofProxy …
Sum types
Sum types (aka Tagged Unions) shouldn't be too surprising. Those are declared via data
and 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
Newtypes
Newtypes are introduced with the newtype
keyword:
newtype UserId = UserId String
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 }
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 )
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
fromList[Int]
(among other things). You can’t create a value of justList
of justOption
, but you can create values ofList[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 "???"
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
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 = ...
We pass it props we care about as a row type:
type ButtonProps =
( asChild :: Boolean
)
buttonExample :: FFIComponent ButtonProps
But if we pass a record by accident, we get that error:
type ButtonPropsOops =
{ asChild :: Boolean
}
buttonExample :: FFIComponent ButtonPropsOops
Could not match kind
Type
with kind
Row Type
🌯 Records and rows are related but different concepts. 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) -> Array a -> Array a
In PS, 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 }
For simplicity, the userId
isn't used in the function body.
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 a field userId
with type String
, a 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) }
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 }
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 }
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
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
🌯 Generics are kinda like shapeless.
Best practices (and concepts)
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 Semiring
s; boolean operation come via HeytingAlgebra
.
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).
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
-- returns Aff Response
{ status, json } <- fetch (apiUrl <> "/users/" <> id) { method: GET }
case status of
-- fromJSON :: forall json. ReadForeign json => Aff Foreign -> Aff json
200 -> fromJSON json
-- throwError :: forall e m a. MonadThrow e m => e -> m a
other -> throwError $ error $ "Unexpected response status: " <> show other
Button click, on the other hand, should respond (have an effect) right away:
{ variant :: String
, size :: String
, onClick :: SyntheticEvent -> Effect Unit
}
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 transform Effect to Aff
liftEffect $ syncLog "This is sync"
case status of
200 -> fromJSON json
other -> throwError $ error $ "Unexpected response status: " <> show other
And what if we want to make an api call on a button click? Make an asynchronous computation (Aff) synchronous (Effect)? 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
button
{ variant: "ghost"
, size: "md"
, onClick: handler preventDefault $ launchAff_ setUserProfile *> log "Clicked"
}
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. This isn't always desirable. In this case, we don’t care about the result, only side effects, so it's ok.
The other option is to use something like the useAff
hook or useAffReducer
to deal with async stuff. For example, button sync updates some state/model, and then that triggers some async action.
setUserId :: UserId -> Effect
useAff userId do
result <- try $ apiClient.getUserProducts userId
liftEffect case result of
Right sub -> setUserProfile user
Left error -> showError error
pure unit
button
{ variant: "ghost"
, size: "md"
, onClick: handler preventDefault $ setUserId someUserId *> log "Clicked"
}
Failure handling
The situation is not much better than in Scala.
-
Option
is calledMaybe
. - Error is the the type of JavaScript errors that lives in
Effect.Exception
(cousins ofThrowable
). You can throws those viathrowException
into, I meanIO
Effect
- There is also
MonadThrow
andMonadError
that, for example, havethrowError
to throw andtry
to catch errors (for instance, inEffect
andAff
).
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-om —
Om
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
andspago@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 abundle
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. Often, 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 editor 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
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
}
)
You can get help with types as well:
userSubscription (user :: ?What)
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
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
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
- PureScript website and Getting Started Guide.
- Free PureScript By Example Book.
- Blog posts: Thomas Honeyman Articles, PureScript: Jordan’s Reference, Mark’s alethic.art.
- Community: PureScript Discord or PureScript Discourse.
- Playground: try.purescript.org
Top comments (0)