Architecture Note: Using PureScript FFI for Safe JavaScript Interop in Web Apps
An architecture note describing how to use PureScript’s FFI to call JavaScript safely, covering requirements, minimal design, trust boundaries, checks, failure modes, and when to redesign.
10 Feb 2026, 05:19 UTC

Requirements
The application needs to call existing JavaScript utilities (e.g., browser APIs, helper libraries) from PureScript while preserving the language’s static guarantees. The call must be type‑safe from the PureScript side, incur no unnecessary runtime overhead, and surface JavaScript errors in a way that PureScript can handle.
Smallest Suitable Design
Declare each JavaScript function as a foreign import with an explicit PureScript type signature. The compiler treats the declaration as opaque, erasing it during code generation and leaving a plain variable reference to the JS identifier.
foreign import mathRandom :: Effect Number
foreign import alert :: String -> Effect Unit
For the example above, mathRandom wraps the global Math.random (which takes no arguments and returns a Number), and alert wraps the browser alert function. The Effect annotation signals that the call may perform side effects.
Trust and Data Boundaries
Values that cross the FFI boundary must be representable as Plain Old JavaScript Objects (POJOs) or primitive types (string, number, boolean, null, undefined). PureScript algebraic data types (ADTs) are not POJOs; they require explicit serialization/deserialization functions to convert to and from POJOs before the foreign call.
Because the FFI is unchecked, the compiler trusts the programmer’s type signature. If the signature does not match the actual JavaScript value, the error appears only at runtime.
Operational Checks
- Compile a minimal module containing the foreign imports with
spago buildorpurs build. - Inspect the generated JavaScript (e.g.,
output/Main/index.js) and verify that each foreign import appears as a simple variable reference, e.g.,var mathRandom = Math.random;. - Run the bundle in a Node.js or browser environment and confirm that the function returns expected values (e.g.,
mathRandomyields a float between 0 and 1). - Introduce a deliberate mismatch—change the signature to
Effect String—re‑compile and observe that the program still compiles but throws a type error at runtime when the result is used as a string.
Failure Modes
- Representation mismatch: Passing a PureScript
Arraydirectly to a JS function expecting a native array will cause the JS engine to receive the PureScript representation (a linked list), leading to unexpected behavior. - Unchecked side effects: If the imported JS function performs hidden mutable state changes, PureScript’s purity reasoning is violated, making refactoring unsafe.
- Asynchronous errors: Errors thrown from promise‑based JS code are not caught by
Effect.tryCatch; they must be lifted intoAffor handled viaPromiseconversion.
Conditions That Would Change the Design
Introducing a large, frequently used JavaScript library with complex data structures would motivate generating PureScript wrappers that automatically serialize/deserialize ADTs, possibly using a code‑generation tool. If the project shifts to a strict content‑security policy that disallows direct evaluation of external scripts, the FFI boundary would need to be mediated through a trusted shim or a Web Worker, altering the trust assumptions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.