Case Study — Using a JavaScript component inside a Haskell application
Mateusz Goślinowski January 08, 2025 [GHC] #javascript #case-studyGHC since version 9.8 allows us to create callbacks from JS to Haskell code, which enables us to create full-fledged browser apps. This article shows how to use the JS backend with foreign component libraries.
- repository: https://github.com/Swordlash/halogen-blog
- ghc used: javascript-unknown-ghcjs-ghc-9.12.1 (ghcup)
Preface
Any useful browser single-page application needs to be able to react to user input to modify the webpage content on response. Since GHC version 9.8 release, we can instantiate JavaScript-level functions with Haskell closures, allowing us to pass Haskell-level actions to DOM event listeners.
foreign
"""
((f) => {
var node = document.createElement("button");
node.textContent = "Click me!";
node.onclick = () => {
node.textContent = `Clicked ${f()} times`;
};
document.body.appendChild(node);
})
"""
main = do
ref <- newIORef 0
let incRef = toJSInt <$> (modifyIORef' ref (+1) *> readIORef ref)
syncCallback' incRef >>= install_handler
In the above snippet we're creating an IORef
and pass a callback incrementing it to a foreign function that installs a button in body, counting a number of clicks
(note for brevity I used a multiline syntax that is not yet available for foreign calls). The callback closes over the IORef
and correctly updates the number after each click.
Callbacks fully enable probably the most fascinating purpose of JavaScript backend, which is web programming. GHCJS has been around for quite some time now,
however it is both outdated (being a GHC fork requiring separate maintenance; currently stuck on 8.10) and cumbersome to use (often necessitating a separate setup, typically through Nix). In one of my previous companies, while evaluating potential options for rewriting the frontend, I decided to use PureScript. It was close enough to Haskell and very easy to set up - it can be installed directly through npm
, has its own stack
-like package manager spago
with a suite of existing bundler plugins, and a blazing fast language server.
During this journey I was using purescript-halogen library - a typesafe, declarative VDOM framework based on The Elm Architecture.
However, with all the joy that the Haskell-like web programming brought me, I couldn't shake the feeling that there is a room for improvement. From the small things that are
always annoying in polyglot stacks (like FE-BE JSON encoding discrepancies or lack of code sharing), to sometimes very illegible purs
compiler error messages and of course lack of a lot of language features. This is not my intention to criticize - for a language this small I was amazed by the number and quality of existing tools and frameworks and plenty of documentation; the overall experience exceeded my expectations by far. I would still recommend PureScript to anyone that wants a lightweight, easy to setup web language with strong type system.
However the mentioned shortcomings were why I was closely following the development of the GHC JS backend, wishing to port the purescript-halogen
library to GHC as soon as it's possible. A fruit of this labour was recently released as haskell-halogen.
Using JavaScript backend with existing JavaScript tools
Yet, even the most complex and technically beautiful undertaking is worthless if its fruits are not usable. One of the measures of such usability in this case is interoperability with the JavaScript ecosystem - existing libraries, bundlers, minifiers, as well of the performance (size and speed) of the generated code.
Optimizing and bundling
Currently, google-closure-compiler
is supported for minification of the bundle (see the blog post on JS code minification). Let's try it.
()
Notice we have to call google-closure-compiler
with two input files: first is the all.js
package generated by the compiler after bundling our library with all dependencies,
GHC's RTS and emscripten RTS files; second, is the all.externs.js
file that declares external variables for the minifier. Its purpose is twofold - It informs the compiler that those variables are declared elsewhere, as not to fail with an "undeclared variable" error, and it prevents the mangling of those identifiers during minification.
Note we had to add --language_in UNSTABLE
when compiling for recent emscripten
due to this issue.
Nice! We reduced size of the js file from 1.8M to merely 396K. This is even smaller after compression:
Now, in the real life we very rarely just serve one file; we usually pull some external dependencies, stylesheets, static files. Those then get processed
into a smaller number of optimized output files in a process called bundling. parcel
is one of the simplest bundlers out there - it scans your html file
for dependencies, recursively checks for js files embedded there and their dependencies, and has sensible defaults for installing browser polyfills on
things that are require
d in code. Let's check it out.
()
Great! Let's check out the page.
And behold!.. an empty page. What's happening? A quick look in dev console shows an issue:
)
)
)
)
Looking at the error, this comes from a parcel polyfill library for process
. Why is process
ever used?
After some digging I found a related issue comment in parcel
and
the affected ghc-internal
line.
So it seems parcel
is wrapping the processed module in a "header" that provides require
, which confuses the ghc-internal
over which environment
it's running in - it thinks that since require
is available it is running in nodejs
, and calls the process.binding
which is not polyfilled.
Yuck!
Now, there is an option to compile without this code entirely and omit the environment checking at all, if you configure your GHC in the following way:
CONF_CC_OPTS_STAGE2="-sENVIRONMENT=web"
This way all environment checking code will be dropped, and emcc
will compile-in only web-related part of its runtime.
However, it being ghc-compile-time option (not link time option unlike in GHCJS), it's cumbersome to use and maintain from GHC side (separate testing, CI etc.), and also half-baked and not supported in all boot libraries (see GHC PR with sub-PRs fixing it in unix
and process
).
Therefore, we need to abandon hopes about parcel
for now. How about webpack
?
;
);
)
;
&&
()
It works! The button appeared on screen and the console errors disappeared.
Bundling with external deps
Now, the above was only child's play since we didn't have any actual external dependencies to bundle, only our code. Let's create something more involved. Let's have our button be styled and managed by Google Material Components library.
Doing it is very simple, as decribed in the Material documentation. We basically have to
- Create an HTML button component structure on page as described in docs:
<!-- this is {element} in below code -->
bookmark
My Accessible Button
- "Activate" the material library on it:
;
;
- Import material styles and icons into your
.scss
bundle - Destroy the element once it's removed from DOM:
buttonRipple.destroy()
.
With this, we can start implementation! file: example2/src/Button.hs
First, a few imports:
We are going to use foreign import for initializing and destroying a button ripple effect. We will include this code in js-sources
field of the package's cabal file:
library
(..)
js-sources: src/Button.js
The js file itself is quite simple and doesn't do much - we import the needed module and expose two mentioned functions.
;
Now, in the haskell file, using Data.Foreign
module of haskell-halogen
(which is just a type-tagged newtype over GHC.JS.Prim.JSVal
), we define our imports:
newtype MDCRipple = MDCRipple (Foreign MDCRipple)
foreign
foreign
Note, because HTMLElement
and MDCRipple
runtime representation is that of a JSVal
, we can specify them directly in signatures without the need of unwrapping arguments into JSVals
and wrapping JSVal
results back.
The Halogen library uses the Elm Architecture, so we need to specify the state of our button component and its actions. Our state will simply be a pair of (foreign MDCRipple, click counter)
. The actions will be Initialize
and Finalize
, called when the element is added and removed from DOM, and Click
:
data Action = Initialize | Finalize | Click
data State = State { ripple :: Maybe MDCRipple, counter :: Int }
Note ripple
is Nothing
at the beginning, when our button is in DOM but the Initialize
hasn't been dispatched yet. Now it's time for the rest of the plumbing:
button = mkComponent $ ComponentSpec
{ initialState = const $ State Nothing 0
, render
, eval = mkEval $ defaultEval { handleAction, initialize = Just Initialize, finalize = Just Finalize }
}
where
ref = RefLabel "mdc-button" -- reference that is added to .mdc-button element, to look it up in DOM and initialize with foreign code
text 0 = "Click me!"
text n = T.pack $ "Clicked " <> show n <> " times"
render State{counter} =
HH.div [HP.class_ (HH.ClassName "mdc-touch-target-wrapper")]
$ pure
$ HH.button
[ HP.classes
[ HH.ClassName "mdc-button", HH.ClassName "mdc-button--outlined", HH.ClassName "mdc-button--icon-leading" ] -- the element in question
, HE.onClick (const Click) -- this action is returned when button is clicked
, HP.ref ref -- we add reference here
]
-- rest of the HTML as the documentation specifies
[ HH.span [HP.class_ (HH.ClassName "mdc-button__ripple")] []
, HH.span [HP.class_ (HH.ClassName "mdc-button__touch")] []
, HH.i [HP.classes [HH.ClassName "material-icons", HH.ClassName "mdc-button__icon"], HPA.hidden "true"] [HH.text "add"]
, HH.span [HP.class_ (HH.ClassName "mdc-button__label")] [HH.text $ text counter]
]
Pretty straightforward, now the action handling:
handleAction Click = modify' $ \s -> s { counter = counter s + 1 }
handleAction Finalize = gets ripple >>= traverse_ (liftIO . destroyRipple)
handleAction Initialize =
getHTMLElementRef ref >>= \case
Just el -> do
r <- liftIO $ initRipple el
modify' (\s -> s { ripple = Just r })
Nothing -> error "Could not find button element"
And that's it. What's left is attaching this component to the <body>
element in Main.hs
:
main = awaitBody >>= void . runUI button ()
Let's run the example.
()
| ;
Oof. google-closure-compiler
doesn't like our import
. And it makes total sense, since it checks for undefined variables, so it doesn't know what are we importing.
Now, there isn't an easy way to fix this. We would essentially have to pass it all node_modules
in question - unfortunately the webpack plugin is outdated; stuck at version Webpack 4.
However, there is a workaround if you really like google-closure-compiler
, and want to use it. What we can do is not bundle the js-sources
, but instead create externs
file for the closure compiler, and bundle the actual implementation later with webpack. To prevent name mangling just in case we hook our functions into window
variable, like
some of the ponyfills do.
src/Button.externs.js
/** @externs */
/** @type {*} */ window.Halogen =;
/** @return {*} */ window.Halogen.init_ripple = ;
/** @return {*} */ window.Halogen.destroy_ripple = ;
src/Button.hs
foreign
foreign
Now we add it to our google-closure-compiler
invocation:
()
Great! It passed! Now we add the real Button.js
to entry files in webpack:
mateusz@m12844:~/personal/halogen-blog$ cat example2-workaround/assets/webpack.config.js
;
;
module.exports =
We also use scss to add styles and font needed:
mateusz@m12844:~/personal/ $ cat /assets/
{
:;
:;
:;
:;
}
{
:;
:;
:;
:;
:;
:;
:;
:;
:;
:;
:;
:;
:;
:;
}
And we're ready:
mateusz@m12844:~/personal/halogen-blog$webpack build --config example2-workaround/assets/webpack.config.js && http-server dist/
(...)
Starting up http-server, serving dist/
Behold, a material button!
Bundling with webpack
and swc-loader
However, while the above trick works for standalone executables, it isn't really useful for libraries that have to ship with full code - haskell and javascript. Requiring downstream users to add separately-shipped externs and js files to their pipeline sounds like an unpleasant thing to do, and exposes them to too much implementation detail. Can we do better?
The answer is - yes, if we ditch the closure compiler but use swc
instead. swc is a Rust-based platform used by a lot of tools like Parser
, Next.js
or Vercel
, and provides its own minifier pipeline with webpack plugin.
What we have to do is, instead of installing our workaround and calling google-closure-compiler
, add the following loader to our webpack file:
// mateusz@m12844:~/personal/halogen-blog$ cat example2/assets/webpack.config.js
module.exports =;
// mateusz@m12844:~/personal/halogen-blog$ cat .swcrc
And voilà! It all works as before. The uncompressed bundle size is slightly bigger (844 KiB vs 803 KiB with google-closure-compiler
) however we don't need any more workarounds,
and we can safely ship our foreign code with imports to our users (provided they do install our npm
dependencies).
A library with richer functionality is available here. At the moment of writing, it contains Halogen components for customizable buttons, lists and tabbed panes.
Conclusion
The above article shows how to use the JavaScript backend effectively and integrate it with foreign libraries, using webpack
for bundling and swc-loader
for minification/mangling.
What's next? There is still a lot to do in terms of code size & performance, as well as integration with other tools:
- This cabal PR adds a new field,
js-options
, that allows passing custom flags tojs-sources
preprocessor. Notably that would enable i.e. conditional compilation of traces along the lines of#ifdef XXX <put-trace>
in foreign library code andif flag(trace-flag) js-options: -optJSP-DXXX
in cabal file. - Low-hanging fruits like adding multiline strings support to inline foreign imports.
- Integration efforts with npm.
What is still a minor unknown is deeper integration of the GHC build pipeline with webpack
build pipeline, in the spirit of gathering npm
libraries that need to be installed for each Haskell dependency, like @material/button
in the above example.
I believe there will have to be a way of declaring inside the cabal package an npm
dependency (something like the existing pkgconfig-depends
) and
a webpack plugin will be created for loading haskell package for bundling.
Personally, I'm going to continue maintaining and developing the haskell-halogen
and haskell-halogen-material
libraries, adding more component classes to the latter.
Thanks!
Many thanks to Serge S. Gulin for his help and discussions on Matrix channel, and to Hécate Kleidukos for inviting me to write this blog post. I want also to thank Sylvain Henry and Luite Stegeman for our mail and PR discussions, and whole IOG GHC Engineering team for the joint effort of releasing the JS backend. Awesome work!