Drawing the FFI Boundary: Safe JavaScript Interop in PureScript
PureScript's FFI lets you call JavaScript, but the compiler cannot see runtime shapes. Here is how to choose between direct bindings and purescript-foreign decoding.
23 Apr 2026, 17:43 UTC

The boundary problem
PureScript compiles to JavaScript, but its type system lives entirely in the compiler. When you cross into JavaScript via the foreign function interface, you leave the compiler's guarantees behind. The temptation is to declare a JavaScript function with a PureScript type and move on. The practical decision is whether that annotation is a contract you trust blindly or a boundary you enforce at runtime.
What foreign import actually promises
The foreign import keyword declares a JavaScript value as a PureScript binding. The compiler checks that the name exists in the corresponding JavaScript module, but it does not inspect the JavaScript to verify that the returned value matches your PureScript type. If you write foreign import fetchCount :: Effect Int, the compiler assumes the JavaScript fetchCount returns a number. If it returns a string, a promise, or null, the failure surfaces at runtime, often far from the call site.
This gap exists because PureScript erases types during compilation. The generated JavaScript contains no runtime type tags for your PureScript types. An Int is just a JavaScript number, a String is just a JavaScript string, and a record is just an object. Without explicit checks, type mismatches become logic errors that are hard to trace.
The unsafe shortcut
The purescript-foreign library provides unsafeCoerceForeign, which reinterprets a JavaScript value as any PureScript type without validation. It is useful when you have absolute certainty about the shape of the data—perhaps because the JavaScript is generated by the same build pipeline. But it breaks parametricity: a function that works for any type now depends on the specific representation of that type. If the JavaScript changes shape, the PureScript code fails silently or crashes with an opaque error.
Use unsafeCoerceForeign only when the JavaScript source is under your control, changes are coordinated, and the value is a primitive or a simple record you can verify by inspection. For anything crossing a trust boundary—API responses, user input, third-party libraries—prefer decoding.
A worked example: decoding instead of casting
Suppose you have a JavaScript module that reads a configuration object from an environment. The PureScript side wants a typed record.
First, the JavaScript file. Place it next to your PureScript module, for example at src/ConfigFFI.js:
// src/ConfigFFI.js
export const readEnvConfig = function () {
return process.env.APP_CONFIG
? JSON.parse(process.env.APP_CONFIG)
: {};
};
Now the PureScript module, src/ConfigFFI.purs:
module ConfigFFI where
import Effect (Effect)
import Foreign (Foreign, ForeignError, readInt, readString)
import Foreign.Index (ix)
import Data.Either (Either(..))
foreign import readEnvConfig :: Effect Foreign
type AppConfig =
{ retries :: Int
, endpoint :: String
}
decodeAppConfig :: Foreign -> Either ForeignError AppConfig
decodeAppConfig f = do
retries <- readInt =<< ix "retries" f
endpoint <- readString =<< ix "endpoint" f
pure { retries, endpoint }
Notice that readEnvConfig returns Foreign, not AppConfig. Foreign is an opaque type representing an arbitrary JavaScript value. You have not lied to the compiler yet.
If process.env.APP_CONFIG is missing the retries field, or if it is a string instead of a number, decodeAppConfig returns a Left with a descriptive ForeignError such as ErrorAtProperty "retries" (TypeMismatch "Int" "String"). You can log or handle that error explicitly. The alternative—unsafeCoerceForeign—would produce an AppConfig where retries is actually a string, and the failure would appear later when arithmetic is attempted.
To verify the example, run purs compile from your project root (or spago build if using Spago). The compiler checks that readEnvConfig exists in src/ConfigFFI.js. It will not check the runtime shape. After compilation, run the generated JavaScript entry point with Node.js. If APP_CONFIG is unset or malformed, the decoder should return a Left value; if you used unsafeCoerceForeign instead, the error would appear later as a type confusion.
Build configuration and module resolution
FFI modules are plain JavaScript files. The PureScript compiler expects named exports matching your foreign import declarations. If you use CommonJS (exports.readEnvConfig = ...) or ES modules (export const readEnvConfig = ...), the compiler and bundler must agree on the format. Modern PureScript projects typically use ES modules, but older setups or specific bundler configurations may require CommonJS.
When using Spago, FFI dependencies are resolved through your spago.dhall and packages.dhall. The compiler version and the versions of purescript-foreign and purescript-effect must be compatible. Before upgrading any of these, check the package set you are using; a mismatch can change decoder APIs or effect signatures.
Trade-offs and limitations
Decoding adds runtime overhead and boilerplate. For hot paths where the JavaScript is a thin wrapper around a primitive calculation, unsafeCoerceForeign may be justified. But the boundary between PureScript and JavaScript is exactly where bugs hide. A decoder is executable documentation: it states what shape you expect and fails loudly when reality disagrees.
Another limitation is backend variance. An FFI module that works with one bundler or compiler backend may require adjustments in another. Module resolution is not fully abstracted away by PureScript; it depends on the JavaScript toolchain you choose. Keep FFI modules small and avoid deep JavaScript dependency graphs inside them.
Actionable takeaways
- Default to
ForeignplusdecodeForeignfor any value originating outside your PureScript code. - Reserve
unsafeCoerceForeignfor internal, tightly controlled JavaScript where the shape is obvious and stable. - Keep FFI modules as thin adapters: one JavaScript file per PureScript module, with named exports matching your imports.
- Verify versions in
spago.dhallorpackages.dhallbefore upgrading, and test the generated JavaScript in your actual runtime, not just the compiler.