How to write PureScript React to replace TypeScript React in 2021
Date: Oct. 07, 2021
By: James Brock
I refactored several thousand lines of TypeScript React into PureScript React. I say “refactor” instead of “rewrite,” because the word “rewrite” sometimes causes too much excitement. Anyway, the word “refactor” is accurate. A TypeScript program contains much more than TypeScript, it also consists of a whole build system with bundlers, a deployment system, a backend, assets, et cetera. We get to keep all of that other stuff. We’re just going to “refactor” one of the passes of the JavaScript transpiler process, and I recommend that that is how you should frame it when you’re describing this process to neophobes.
Here are my notes about the parts of the refactor which I found to be tricky. Additions or corrections to this document will be gratefully accepted, please create an Issue or a PR on Github.. Discuss on the PureScript Discourse.
On TypeScript
TypeScript is an example of family of languages which John Backus characterized as “fat and weak”. He included his own invention, FORTRAN, in this family.
The fatness of TypeScript is apparent in its complicated specification and noisy syntax. The weakness is apparent when we try to write useful programs with TypeScript, and discover that it’s basically impossible, and then we resort to external domain-specific languages because we can’t express what we want to express in TypeScript. That’s why a typical TypeScript React program will usually contain the following external domain-specific languages:
- TSX
- styled-components or emotion templates
Also, the TypeScript type system is underpowered and has too many escape
hatches. It’s been my
observation that when TypeScript programmers are passing a string to a function
they will dutifully annotate it as type :string
, but when the types get
difficult they give up and pass :any
. It’s in exactly those difficult
situations that we need the compiler’s help checking the type.
On PureScript
What is PureScript? It is a dialect of Haskell.
PureScript and Haskell are not exactly the same language, and the PureScript maintainers insist that compatibility with Haskell code is a non-goal. But let me put it this way: if you learn PureScript, then you will find that you have also learned Haskell. And likewise: if you already know Haskell then you already know PureScript.
PureScript, being relatively lean and powerful, won’t need any external domain-specific languages. We’ll just express our program in plain PureScript.
On PureScript React Basic Hooks
A. React is a framework for declarative functional programming with constrained side-effects.
B. PureScript is a language for declarative functional programming with constrained side-effects.
C. TypeScript is a language for imperative object-oriented programming with unlimited side-effects.
React and PureScript combine together much better than React and TypeScript.
If you want to write web applications in 2021, then purescript-react-basic-hooks is a very good framework choice.
There are many other PureScript immediate-mode GUI libraries. Most of them were written by Phil Freeman. purescript-react-basic-hooks was written by Madeline Trotter, and it’s the best PureScript immediate-mode GUI library.
The most remarkable thing about purescript-react-basic-hooks is the
Hook
indexed monad.
To understand why Hook
is an
indexed monad,
please read this
short and inaccurate dramatization
of a conversation which I had at Shake Shack at the International Forum in Tokyo.
Me:
I want to loop over some Hook
s, but
I can’t find traverse
for the Hook
monad and the compiler won’t let me write it.
What gives?
Robert Porter:
Calling a Hook
in a loop is forbidden by the
Rules of Hooks.
That is why Hook
is an indexed monad; because
Madeline Trotter noticed that
the algebraic structure of the indexed monad matches neatly with
the Rules of Hooks, so she created the Hook
indexed monad.
In TypeScript the only way to stop users from violating
the Rules of Hooks is by linting or scolding. In PureScript React Basic
Hooks, a Rules of Hooks violation is a compile-time type error.
Me: jaw slackens on my Shack Stack That makes so much sense.
Our strategy for refactoring the whole program
When refactoring (“rewriting”) a computer program into another language, one often must simply refactor the whole program and it won’t be done until it’s done.
In some lucky cases, there exists good FFI bindings between the source and target languages. Then it’s possible to swap out parts of the old program and replace them with parts written in the new language. The key is to find good “parts,” to find boundaries out of which a section of the program can be cleanly pried and replaced, ship-of-Theseus-style.
For refactoring from TypeScript React to PureScript React Basic Hooks, the situation is very lucky, because we have very natural clean boundaries for replacement: the React components.
We will refactor the TypeScript into PureScript one React component at a time, and our ship will remain seaworthy at every step.
The most mentally taxing programming will be at the interoperation boundary between PureScript and TypeScript. There’s a lot of boilerplate code at that boundary, and since we’re jumping between type systems, the compiler can’t help us with typechecking. For that reason, we’ll want to pry out sections larger than a single React component as we gain momentum on our refactor. Pick a top-level React component to replace and then recursively replace of all of the components it depends on, in a single step. We can pretty much go file-by-file, and line-by-line.
If we have file named TopPage.tsx
, then we can create a TopPage_.purs
right next to it, and start writing. We would be able to create TopPage.purs
except that we might want some
foreign imports, in which case we would need TopPage.js
,
which would conflict with TopPage.tsx
while bundling.
TopPage_.js
will not conflict.
PureScript-TypeScript interop
Notes on how to write the interface between PureScript and TypeScript.
Our point of interface will almost always be the
ReactComponent
. We want to call PureScript React components from
TypeScript, and vice versa.
PureScript FFI
We also must understand how FFI works in PureScript.
github.com/purescript/documentation/blob/master/language/FFI.md
github.com/purescript/documentation/blob/master/guides/FFI.md
Thomas Honeyman’s article
How to Write PureScript React Components to Replace JavaScript and discussion: discourse.purescript.org/t/updated-how-to-replace-react-components-using-purescripts-react-libraries/
TypeScript Union Types
There is no equivalent PureScript built-in feature which compiles to the same runtime representation. But these libraries can help.
github.com/natefaubion/purescript-variant
github.com/jvliwanag/purescript-untagged-union
github.com/paluh/purescript-undefined-is-not-a-problem
github.com/doolse/purescript-tscompat
github.com/justinwoo/purescript-ohyes — OhYes, you can interop with TypeScript using PureScript
TypeScript String Literal Union Types
type Alignment = "left" | "right" | "center"
This is common idiom in TypeScript. The runtime representation of these things is just a string. How do we make an equivalent thing in PureScript with the same runtime representation? It’s a puzzle.
Maybe with Symbol
, the PureScript type-level string.
Or github.com/natefaubion/purescript-variant.
TypeScript Intersection Types
interface ErrorHandling {
success: boolean;
error?: { message: string };
}
interface ArtworksData {
artworks: { title: string }[];
}
type ArtworksResponse = ArtworksData & ErrorHandling;
We can use PureScript Row Polymorphism to create equivalent PureScript types with the same runtime representation.
type ErrorHandlingRow r =
( success :: Boolean
, error :: Nullable { message :: String }
| r
)
type ArtworksDataRow r =
( artworks :: Array { title :: String }
| r
)
type ArtworksResponse = Record (ArtworksDataRow (ErrorHandlingRow ()))
Calling TypeScript React components from PureScript
Suppose we have this TypeScript React component, and we want to wrap it so that we can call it from PureScript.
src/Tags.tsx
export interface Props_tags {
tags: [string]
}
export default (props:Props_tags) => {
...
}
To wrap the foreign Tags
component in PureScript,
create files Tags_.purs
and Tags_.js
.
src/Tags_.purs //: # as of 2022.07.30 Jekyll highligher rouge does not support purescript. So typescript is used instead below. Adding purescript.rb in plugins will not work
module Tags (tsxTags) where
import React.Basic (ReactComponent)
-- | Must agree with the TypeScript `interface Props_tags`.
type Props_tags =
{ tags :: Array String
}
foreign import tsxTags :: ReactComponent Props_tags
src/Tags_.js
"use strict";
exports.tsxTags = require('src/Tags').default;
(The tsx
prefix convention makes it easy to see which components in .purs
files are foreign.)
Then we can use the foreign component:
import React.Basic.DOM (div_)
import React.Basic.Hooks (element)
import Tags (tsxTags)
...
div_ [ element tsxTags {tags:["one"]} ]
React diffing algorithm and foreign import
Here is more helpful advice from Robert Porter, about typeclass constraints on foreign imports of React components. Recent versions of PureScript have deprecated typeclass constraints on foreign import, so you probably don’t have to worry about this, but here it is just in case.
The React diffing algorithm uses referential equality for the props.
If a PureScript component has class constraints, then the constraint dictionary will get passed on every render, and React will consider the constraint dictionary to be part of the props. A new constraint dictionary object will be created on each call, so the constraint dictionary will not compare equal to the contraint dictionary from the last call, which will cause the React diffing algorithm to re-render the component on every render.
If the component reference it returns is the same every time, then the problem won’t occur. You are more likely to run into this problem with a component written in PS that uses constraints, because the compiled JS will return a new component reference every time it runs.
The way around this is by making a local wrapper alias that fixes all the constraints to known types, thus avoiding the problem of re-evaluation.
From React’s point of view, constrained components in the JSX are acting kind of like Effect
. So it can return a “new” component every time. So either satisfy the constraints before using it in the JSX, or make sure that it’s a “pure” effect that returns the same (referentially equal) value every time.
A foreign import
ReactComponent
will return the same (referentially equal) value every time.
Calling PureScript React components from TypeScript
I like to use the top-level unsafePerformEffect
technique for creating exportable PureScript
React components, even though Madeline Trotter “wouldn’t say it’s the right thing to do.”
Ambient Definition file
We have a types/purs
directory for TypeScript definition files.
For every MyModule.purs
which exports a ReactComponent
named psxThing
in a MyModule
module, we’ll need to create
a types/purs/MyModule.d.ts
which declares a TypeScript module.
There are two types of React components: ComponentClass
, which is the “traditional” “classic” class-based
component, and FunctionComponent
, which is the Hooks-based component type.
We’ll use the type FunctionComponent
, because that corresponds to the
ReactComponent
type returned by
React.Basic.Hooks.reactComponent
.
https://www.typescriptlang.org/docs/handbook/modules.html#ambient-modules
types/purs/MyModule.d.ts
declare module 'purs/MyModule' {
import { I_Props } from "propsinterface"; // Do any TypeScript imports here
const psxThing : React.FunctionComponent<I_Props>;
}
TSX
TSX components names must have an uppercase first letter. PureScript
component names must have a lowercase first letter.
Our convention is that in a .tsx
file, we import PureScript components
like psxThing
and then alias them.
import {psxThing as PSXThing} from 'purs/MyModule';
<PSXThing/>
Then we can look at .tsx
files and see how many of the components are PSX
.
And when we’re finished, then we can change all of the psx* :: ReactComponent
FFI components into native React Basic Hooks psx* :: Component
.
react-router and history.push()
We published
purescript-react-basic-router
so that we can push
to a react-router-dom History
object.
How to getElementById
A bit tricky, so here is the trick:
import Web.DOM.Document (toNonElementParentNode)
import Web.DOM.NonElementParentNode (getElementById)
import Web.HTML (window)
import Web.HTML.HTMLDocument (toDocument)
import Web.HTML.Window (document)
do
rootElMaybe <- getElementById "root" =<< toNonElementParentNode <$> toDocument <$> (document =<< window)
React Transition Group
The whole point of React Transition Group is that when we want to trigger a CSS animation right before a component gets unmounted, then we want to wait for the CSS animation to finish before we unmount the component. That’s the essential feature.
I recommend using
Effect.Aff.delay
to get the same feature.
Suppose we want to remove an icon after a 20-second vanishing animation.
React.do
icon /\ setIcon <- useState true
iconOpacity /\ setIconOpacity <- useState "1"
let iconVanish :: Effect Unit
iconVanish = do
setIconOpacity $ const "0"
launchAff_ do
delay $ Milliseconds 20000.0
liftEffect $ setIcon $ const false
pure $ if icon
then R.img
{ style: R.css
{ opacity: iconOpacity
, transition: "opacity 20s ease-in"
}
}
else empty
I’ve been advised by my colleague that this is not a good technique, because
the whole component might get unmounted while the delay
is waiting,
and then setIcon
will be called on the unmounted component. So maybe
useAff
instead of launchAff_
would be better here.
Foreign
Question: In my PureScript program, I’ve recieved a foreign JSON object, which I expect to have a particular structure. How do I safely “cast” that to a PureScript data type?
Or maybe I don’t have any expectations about the structure of the JSON, and I want to read the JSON and discover its structure?
This is a super common question, and I was using PureScript for years before I figured out what best answers were.
The classic essay on the general problem of how to read unstructured untyped data into a typed data structure is Parse, don’t validate and I strongly recommend this essay.
1. Argonaut
The decodeJson
function from argonaut can infer the structure of the JSON you’re expecting from the type of the data that you want to cast it to. If the structure of the JSON doesn’t match the type, then it returns an error in Left
.
show $ do
x :: Array {a::Int,b::String} <- decodeJson =<< parseJson """[{"a":2,"b":"stuff"}]"""
pure x
Results in (Right [{ a: 2, b: "stuff" }])
See the argonaut-codecs Quick start for more decodeJson
examples:
https://pursuit.purescript.org/packages/purescript-argonaut-codecs
If you want to decodeJson
for some type that doesn’t already have a DecodeJson
instance, then you can write a DecodeJson
instance for your type.
If you want to discover the structure of the JSON, you can write monadic parsers in the Either
monad with the getField*
functions. You can also preview
the Json
with Argonaut.Prisms
.
2. Simple.JSON
The Simple.JSON.read'
function can infer the expected structure of JSON from the PureScipt data type that we are trying to read into.
https://purescript-simple-json.readthedocs.io/en/latest/intro.html
The automatic decoding in Simple.JSON
is based on theReadForeign
typeclass instead of the DecodeJson
typeclass.
3. F Monad
The most powerful and general way to read foreign data is by writing monadic parsers for the F
monad. You run the parser with runExcept
.
If blob :: Foreign
is a JSON object which we expect to be an array of records, each with a string field named "thing"
, then we can parse it into PureScript with the F
monad like this:
import Foreign (Foreign, readArray, readString)
import Foreign.Index (readProp)
import Control.Monad.Except (runExcept)
result :: Either MultipleErrors (Array {thing :: String})
result = runExcept do
xs <- readArray blob
for xs \x -> do
t <- readString =<< readProp "thing" x
pure {thing:t}
Then the result
will be either the array of records, or a list of errors explaining exactly how the JSON structure was not what we expected it to be.
4. codec-argonaut
“The codec-argonaut library is used by those of us who like a less typeclass-reliant version of handling things too.”
vscode
I recommend these extensions:
- PureScript IDE
- PureScript Language Support
- Vim
- Remote - SSH
- GitLens
- Dhall Language Support
- Nix Environment Selector
Image inlining
In most bundlers, there is a technique by which one can import
an image as a string
,
so that it gets compiled into inline JavaScript which looks like this:
var spinner = "data:image/png;base64,iVBORw0KGgoAAA.....";
This works, for example, with rollup.js and an appropriately configured @rollup/plugin-url
.
For this TypeScript:
import spinner from 'assets/spinner.png';
We can accomplish the same thing in PureScript like this:
Assets.purs
module Assets where
foreign import spinner :: String
Assets.js
"use strict";
exports.spinner = require('assets/spinner.png').default;
CSS
When we want to write inline CSS instead of a stylesheet but we also want to use CSS selector combinators, then we will want a CSS class generation library. Either the older styled-components or the newer, better emotion.
styled-components is not available in PureScript React Basic, but purescript-react-basic-emotion is available, and very good. The syntax and behavior of styled-components and emotion is almost exactly the same, so it’s easy to refactor TypeScript with styled-components into PureScript with emotion.
Library substitutions
TypeScript | PureScript | |
---|---|---|
XMLHTTPRequest |
axios | affjax |
CSS class generation | styled-components @emotion/styled | react-basic-emotion |
React Router Web | @types/react-router-dom | react-basic-router |
String interpolation | interpolate | |
Loader for WebPack | purs-loader craco-purscript-loader | |
State Management | @types/react-redux | React is already a state management framework, you don’t need redux or anything else. Use a State Hook instead. |