Intro to PureScript for TypeScript developers

Teaser for the benefits of strongly typed pure functional programming

PureScript is a statically typed general-purpose programming language inspired by Haskell, compiled into JavaScript. The vision of the language is to make frontend development more productive by leveraging an expressive type system and primarily focusing on supporting functional programming techniques.

To see why such a language might be interesting, first let's take a short detour to see what might be missing from TypeScript, one of the most popular languages for the frontend nowadays.

The Limitations of TypeScript's Type System

TypeScript's type system is powerful, but there are certain things that it does not care about.

Consider the following function:

function divide(a: number, b: number): number {
  ...
}

Can you guess the implementation?

Well, it could be a / b, but it could be also this:

function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  const result = a / b;
  console.log(`${a} divided by ${b} is ${result}`);
  globalState.mystuff = 'workwork';
  http.post('https://my-example.com', { data: 'dummy data' }).catch((error) => {
    console.error('API error:', error.message);
  });
  return result;
}

Unarguably, this method does a bit more than described in the signature:

  • The function logs to the console, sends an HTTP request, and changes the state of an object as a side effect.
  • The caller is forced to handle the potentially thrown exception explicitly.

For divide only the return type is guaranteed by the type system, but it is completely blind about these characteristics. However, they are very important. If the function throws an exception, the call site has to prepare for that because it can change the control flow. If it performs an HTTP request or changes the global state, it might impose limitations on where and when it can be used.

Furthermore, since the type system is unaware of these factors, they can easily go unnoticed. While they are easily identifiable in this simple example, they may be present in any function calls executed by this function.

With TypeScript one can encode all this information through types, but the language does not impose any strict enforcement on it.

Enter PureScript

PureScript approaches this question a bit differently. The goal of the language is to encourage users to use purely functional constructs almost everywhere instead of allowing a mix of multiple paradigms and to support it with an expressive and strict type-checking mechanism.

PureScript's "strict" and "purely functional" nature makes it more picky when it comes to compiling code, but it also offers some unique features to support writing code in functional style.

First functions

In PureScript everything is an expression. There's no special syntax for function declaration. They can be created anywhere and can be returned or passed as arguments:

welcomeMessage greeting name = greeting <> " " <> name <> "!"

welcomeMessage takes two arguments and can be used as follows:

welcomeMessage "Hi" "John"
-- It returns the String "Hi John!"

PureScript has a powerful type-inference mechanism, so it's not required to explicitly add the type annotations, but for clarity it's recommended to do so for top level functions. With additional types, the definition looks like the following:

welcomeMessage :: String -> String -> String
welcomeMessage greeting name = greeting <> " " <> name <> "!"

This leads to the first interesting language feature: all functions have just one parameter.

If you pass only a single argument to this function, it will return a function that takes a single string:

welcomeMessageHu :: String -> String
welcomeMessageHu = welcomeMessage "Szia"

welcomeMessageHu "John"
"Szia John!"

When welcomeMessage is called with two arguments, it's equivalent to calling the function one argument, then calling the resulting function with the second. With this one can create new functions without additional efforts from existing ones with some arguments already injected.

This is also possible in other languages, but typically it requires some work to transform a function into a form to work like this.

Everything is in the types

While focusing primarily on pure functions may require additional effort initially, it ultimately alleviates concerns regarding hidden exceptions and side effects in the long term.

divide :: Int -> Int -> Maybe Int
divide x y =
  if y == 0 then Nothing
  else Just (x / y)

The Maybe type can be considered very similar to Optional in Java. In case of a zero divisor the function returns Nothing, the "empty" version of Maybe. Otherwise it just wraps the result of the calculation. This way, there's no need to use a different language construct (a try-catch) to handle the unusual case because everything is encoded in the returned value.

Unwrapping the Maybe type to the value can be done explicitly on the caller side by checking imperatively if Maybe is Nothing, but there are better approaches.

Most wrapper types like Maybe support map to perform operations on their contents without explicitly dealing with the Nothing case. This is very similar how map is used in JavaScript to transform arrays. With this it's possible to define a chain of operations without explicitly dealing with the Nothing case:

map (\n -> n + 1) (map (\n -> n * 3) (divide 6 2))
-- Just 10

If divide returns Nothing, no operations will be performed. Otherwise, the map calls will operate on the wrapped value.

It's also possible to combine multiple functions that return a wrapper with concatMap or bind (in TypeScript it's called flatMap). PureScript even provides a nice syntactic sugar for it in the form of the do notation to avoid deeply nested code:

calculation :: Maybe Int
calculation = do
  a <- divide 6 2
  b <- divide 9 0
  c <- divide 8 4
  pure (a + b + c)

The symbol <- can be read as "selects" while pure is to transform the resulting value back to the Maybe type. In case one of the divide calls returns Nothing the result of the whole function will also be Nothing and calling further function calls will be skipped.

It can be made even more interesting with the Either type which is like Maybe but also holds a potential error message.

Handling side effects

If Maybe is a wrapper for a value that may or may not exist, then Effect is a similar wrapper for a value that may produce side-effects when it is computed.

divide :: Number -> Number -> Effect Number
divide a b = do
  let result = a / b
  log $ show a <> " divided by " <> show b <> " is " <> show result
  pure result

Effect in the signature makes it obvious that the function has a side effect, but it also prevents the function from being called from pure functions.

With this PureScript enforces the separation of actions (e.g. side-effecting code) and pure calculations. Effect can be generally used for side-effecting code, not just logging, like changing mutable state, sending an HTTP request, manipulating the DOM, or even throwing an exception.

Summary

To wrap it up, the key features of PureScript are enforcing purely functional programming and having a strong type system. Both aspects can be considered as incentives to utilize the language but also as deterrents from using it.

Due to these two characteristics requires the user to encode most things as types, giving a compile-time feedback about correctness instead of finding surprises much later at runtime. Providing earlier error detection is a plus, but it also means more up-front work is required to get to working code.

Being purely functional means it's very different from most popular languages. Because of this PureScript might also be a good learning tool to get immersed in functional programming. While it's possible to use functional programming techniques in TypeScript, it is a multi-paradigm language, allowing the users to write object-oriented and procedural code; as a user it requires discipline to use functional programming to the fullest and not to revert to old habits of object-oriented or procedural programming at the first obstacles.

On the flip side, learning opportunity also means learning requirement when it comes to onboard new people to PureScript code, making it hard to scale teams.

In this post I did my best to completely avoid functional programming jargon in order to focus on PureScript's important characteristics, but one needs to learn about category theory very soon in order to work effectively with the language. Analogies like "it's just a wrapper" or "it's almost the same as Optional from Java" are not sufficient and can lead to misunderstandings or incorrect implementations. This probably is one of the reasons why PureScript (and it's spiritual ancestor, Haskell) are used in the industry, they remain niche languages.

I believe knowing about practical functional programming can make one a better engineer. If I made you interested, I recommend the following sources to deep dive into this topic:

May 18, 2023
In this article