PureScript FFI: Raw foreign import versus purescript-foreign boundary validation
Decide when to use raw PureScript FFI versus purescript-foreign boundary decoding for safer JavaScript interop.
03 Aug 2025, 05:15 UTC

The decision and constraints
You need to call a JavaScript function from PureScript using an FFI (foreign function interface), the mechanism that lets one language invoke code written in another. The boundary between the two languages is where type safety usually breaks down: PureScript can promise a value is an `Int`, but the JavaScript side can return `null`, a string, or an object with the wrong shape. If that mismatch reaches application code, it can surface as a silent crash or an uncaught exception far from the FFI declaration.
Constraints for this decision:
- Type safety at the boundary, not just inside PureScript modules.
- No silent runtime crashes from malformed JavaScript values.
- Auditable interop code so a reviewer can see exactly what JavaScript is being called.
Useful takeaway: reserve raw `foreign import` for trusted, stable JavaScript shapes you control. Use `purescript-foreign` decoders when the JavaScript value comes from an external API, a third-party library, or any shape that might change without a PureScript recompile.
Supported options side by side
| Option | Mechanism | Safety at boundary | Overhead | Best for |
|---|---|---|---|---|
| Raw `foreign import` | Direct call to a JavaScript value; often paired with `unsafeCoerce` to fit PureScript types. | Unchecked. Wrong arity or types throw at runtime, not compile time. | Near-zero; almost no wrapper code. | Internal, trusted JavaScript functions with stable signatures. |
| `purescript-foreign` `decode` / `read` | Validates a JavaScript value against a PureScript type and returns `Either` or `Effect Exception` on mismatch. | Checked. Mismatches become explicit `Left` values instead of exceptions. | Small bundle size plus wrapper functions; runtime validation cost. | Untrusted or changing JavaScript shapes, especially JSON-like data. |
Trade-offs to consider
Raw FFI is minimal. A declaration such as `foreign import jsAdd :: Int -> Int -> Effect Number` tells PureScript what to expect, but PureScript cannot verify that the JavaScript export actually accepts two numbers or returns a number. If the JavaScript function has the wrong arity or returns `undefined`, the error appears only when the call executes.
`purescript-foreign` moves the check to the boundary. A decoder inspects the JavaScript value and returns `Left` with a description when the shape is wrong. `Either` is a type that represents success (`Right`) or failure (`Left`). The cost is a small amount of wrapper code and runtime validation. Avoid running decoders inside tight loops or high-frequency event handlers; validate once at the edge, then work with the decoded PureScript value.
Effect tracking matters too. Use `Effect` for synchronous JavaScript calls. Use `Aff` for JavaScript functions that return Promises. Mixing them can lose asynchronous results or produce unhandled exceptions because PureScript’s effect system no longer tracks the Promise lifecycle.
Version note: FFI syntax has been stable since PureScript 0.13, but `purescript-foreign` module names and deriving utilities changed between major releases. Check the installed release before copying code from older examples.
Implementation: wrap a raw import with a decoder
This example calls a synchronous JavaScript addition function, then validates the result with `purescript-foreign`. The raw import stays in a small foreign module, while the PureScript wrapper owns the validation.
JavaScript foreign module (`foreign/js-add.js`), run in the project root:
exports.jsAdd = function (a) {
return function (b) {
return function () {
return a + b;
};
};
};
PureScript module (`src/JsAdd.purs`), compiled with the PureScript compiler:
module JsAdd where
import Prelude
import Effect (Effect)
import Foreign (foreign)
import Foreign.Object (Foreign)
import Data.Either (Either(..))
import Data.Foreign (decode, (!!), object)
import Data.Foreign.Class (decodeValue)
foreign import jsAdd :: Int -> Int -> Effect Number
-- Decode a JavaScript value into a PureScript record, then call the raw FFI.
decodeAdd :: Foreign -> Either String (Int -> Int -> Effect Number)
decodeAdd foreignValue = do
a <- decodeValue (object !! "a" :: Foreign -> Either String Int) foreignValue
b <- decodeValue (object !! "b" :: Foreign -> Either String Int) foreignValue
pure (\_ _ -> jsAdd a b)
Where to run commands: terminal at the project root. Required permissions: normal user access to the repository; no sudo is needed for compilation or local Node execution. Meaningful placeholders: replace `src/JsAdd.purs` and `foreign/js-add.js` with your actual module paths.
Expected checks:
- `purs --version` prints a compiler version; confirm it matches the project’s `spago.dhall` or `bower.json` toolchain.
- `purs compile src/**/*.purs foreign/**/*.js --output output` exits with status 0.
- Running the compiled output in Node calls `jsAdd` and returns a number for valid inputs.
Relevant risks: a wrong FFI type signature is still a runtime error in PureScript. The compiler trusts the `foreign import` declaration, so it will not catch a JavaScript function that expects a string where PureScript declares `Int`.
Validation: confirm malformed input produces Left, not an exception
Compile the module, then execute it in Node with a JavaScript object missing a required field. A correct decoder returns `Left` describing the missing field. An incorrect wrapper might throw before PureScript can handle the failure.
Practical check: pass `{ b: 2 }` to a decoder expecting `{ a :: Int, b :: Int }`. Assert that the result is `Left` and that the process does not terminate with an uncaught exception. If you see a thrown error instead, the boundary validation is not wrapping the JavaScript call correctly.
Limitations
- Raw FFI skips type checking on the JavaScript side entirely.
- `purescript-foreign` adds runtime cost; do not validate the same payload repeatedly in hot paths.
- Decoder combinators and module paths vary across `purescript-foreign` major versions; verify the installed release before adapting examples.
There is no rollback step because this guide only adds read-only interop code and compilation artifacts. To remove an FFI binding, delete the `foreign import` declaration, its JavaScript export, and any call sites, then recompile.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.