Calling JavaScript from PureScript: A Practical Guide to the FFI
PureScript’s Foreign Function Interface (FFI) lets you call JavaScript directly. This article walks through a real example, highlights common pitfalls, and shows how to keep your code safe and maintainable.
19 Oct 2025, 16:52 UTC

Why you need the FFI in PureScript
PureScript is a strongly typed, pure language that compiles to JavaScript. When you need to use an existing JS library, or access browser APIs that have no PureScript binding, you turn to the Foreign Function Interface (FFI). The FFI is the only way to call out of the type‑checked PureScript world into raw JavaScript.
How the FFI works – the basic workflow
Every foreign call is declared in two places:
- A PureScript module that uses the
foreign importorforeign exportkeyword. - A separate
.jsfile that contains the actual JavaScript implementation.
When you compile with purs compile, the compiler generates a JavaScript module that stitches the PureScript code and the JS glue together. The result is a single bundle that can be fed to a bundler like Rollup or Webpack.
Step 1 – Declare the foreign function in PureScript
module Math.Add where
import Effect (Effect)
import Effect.Unsafe (unsafePerformEffect)
foreign import add :: Int -> Int -> Effect Int
The add function is declared as an effectful call that takes two Int arguments and returns an Effect Int. The type signature tells the compiler how to marshal values to and from JavaScript.
Step 2 – Provide the JavaScript implementation
// math_add.js
function add(a, b) {
// a and b are JavaScript numbers
return a + b;
}
module.exports = { add };
Notice that the file is named math_add.js to match the PureScript module path (replace dots with underscores). The exported object must contain a property with the same name as the PureScript function.
Step 3 – Compile and bundle
# Compile the PureScript code
purs compile Math/Add.purs -m Math.Add -o dist
# Bundle with Rollup (or your preferred tool)
rollup -c rollup.config.js --file dist/bundle.js
In the Rollup config you import the generated dist/Math/Add.js module, which internally pulls in math_add.js. The final bundle.js contains both the PureScript runtime and the JavaScript glue.
Calling the foreign function at runtime
Once bundled, you can run the code in Node or a browser:
const { add } = require('./dist/bundle.js');
add(3, 4).then(result => console.log('Result:', result));
Because add returns an Effect Int, it is represented as a JavaScript promise. The runtime automatically resolves the promise with the JavaScript result.
Common pitfalls and how to avoid them
- Type mismatches – The PureScript compiler checks the signature at compile time, but the JavaScript side has no type guarantees. If you rename the exported property or change the argument order, the call will silently fail or crash at runtime. Keep the JS file under version control and run automated tests that exercise the foreign functions.
- Exception propagation – JavaScript exceptions are not automatically caught by PureScript. A thrown error will reject the
Effectpromise. Wrap calls withEffect.tryCatchto convert them into anEither Error a. - Garbage‑collection pressure – Passing large objects between PureScript and JavaScript creates many temporary JavaScript values. Use typed arrays or Stream APIs when working with big data.
- Tree‑shaking – The generated bundle includes all exported foreign functions, even if you never use them. Explicitly import only what you need, or use a bundler plugin that removes unused exports.
Example: Safe foreign call with error handling
Let’s extend the previous example to handle a JavaScript error. Suppose the JS function throws if the second argument is zero:
// math_add.js
function add(a, b) {
if (b === 0) throw new Error('B cannot be zero');
return a + b;
}
module.exports = { add };
In PureScript we use Effect.tryCatch to capture the exception:
module Math.SafeAdd where
import Effect (Effect)
import Effect.Exception (Error)
import Effect.Unsafe (unsafePerformEffect)
import Effect.Aff (Aff)
import Control.Monad.Error.Class (throwError)
foreign import unsafeAdd :: Int -> Int -> Effect Int
safeAdd :: Int -> Int -> Effect (Either Error Int)
safeAdd a b = Effect.tryCatch (unsafeAdd a b) (Right <<< Left)
Now safeAdd returns an Either Error Int, allowing callers to decide how to handle the failure.
Trade‑off: PureScript type safety vs. JavaScript flexibility
The FFI gives you full access to any JavaScript library, but it also bypasses PureScript’s type system. You must:
- Document the expected JavaScript types.
- Write unit tests that exercise the foreign boundary.
- Keep the JS glue in sync with the library’s API changes.
When you can, prefer pure PureScript libraries. Reserve the FFI for:
- High‑performance APIs that have no PureScript wrapper.
- Browser APIs that are not yet wrapped.
- Legacy code that cannot be rewritten.
Actionable next steps
- Identify the JavaScript function you need to call and write a matching PureScript type signature.
- Create the
.jsglue file following the naming convention. - Compile with
purs compileand bundle with your chosen tool. - Write a small test that calls the foreign function and asserts the result.
- Wrap the call in
Effect.tryCatchif the JavaScript side can throw. - Add a CI step that runs the test after every change to the foreign file.
By following this pattern, you can safely extend your PureScript application with JavaScript while keeping the benefits of strong typing and referential transparency.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.