Your PureScript FFI Types Are Unchecked Assertions
PureScript's foreign import lets you call JavaScript with typed signatures, but the compiler never inspects the JS body. Learn how to treat FFI as an untrusted boundary and validate at the edges.
29 Aug 2025, 00:08 UTC

You wrap a JavaScript string utility in PureScript, declare a foreign import with the type you expect, and the compiler accepts it without a murmur. Later, the application crashes because the JavaScript function received a number where your annotation promised a string. The gap between PureScript's type checker and JavaScript's runtime reality is the FFI boundary, and crossing it safely is an engineering decision, not a compiler guarantee.
Thesis: Treat every foreign import as an untrusted assertion. The compiler enforces PureScript's rules on the calling side, but it cannot inspect the JavaScript implementation. Your job is to keep that boundary thin and validate at the edges.
The foreign import Trust Boundary
PureScript's foreign function interface (FFI) is the supported mechanism for calling JavaScript. You use the foreign import keyword to declare an external value with a PureScript type. The compiler then treats that value as opaque: it checks that a corresponding JavaScript module exports a matching name, but it does not read the JavaScript body.
This is different from ordinary PureScript code, where the compiler infers and verifies types from the implementation. With FFI, the type annotation is a contract you write, not one the compiler proves. If the JavaScript returns null where you claimed String, the compiler has no way to know.
Why the Compiler Cannot Check Your JavaScript
PureScript compiles to readable JavaScript, and the generated output retains the original foreign function name. When you call an imported function, the emitted code invokes the JavaScript export directly. Because the implementation is opaque to the compiler, there is no cross-boundary type checking at compile time.
The FFI does support simple types such as Int, String, Boolean, and Array, as well as more complex shapes via newtype wrappers or the Foreign module for JSON decoding. However, those mechanisms describe the data you expect; they do not verify that the JavaScript actually produces it.
Worked Example: A String Function That Lies
Suppose you want to uppercase a string using a small JavaScript helper. Create two files in your project directory.
src/Main.purs:
module Main where
foreign import shout :: String -> String
greeting :: String
greeting = shout "ffi boundary"
src/Main.js:
"use strict";
exports.shout = function (s) {
return s.toUpperCase();
};
From the project root, compile with:
purs compile 'src/**/*.purs' -o output.js
Expected check: the compiler emits output.js without errors. Run the generated module with Node to confirm the value:
node -e "console.log(require('./output.js').greeting)"
If the annotation matches the JavaScript, the call succeeds. Now change the PureScript annotation to Int -> String and update the call site to shout 42:
foreign import shout :: Int -> String
greeting :: String
greeting = shout 42
The compiler still accepts this. PureScript Int maps to a JavaScript number, so the generated code passes 42 into exports.shout. Inside JavaScript, numbers do not have a toUpperCase method, so the runtime throws. The compiler never warned you because it never inspected the JavaScript body.
Keeping the Boundary Honest
You cannot make the compiler verify JavaScript, but you can reduce the risk.
- Keep the FFI surface minimal. Import the raw JavaScript function once, then wrap it in a PureScript function with a precise type. Let the rest of your codebase depend on the wrapper, not the raw import.
- Use
Foreignfor unknown data. When a JavaScript API returns JSON or values you do not control, decode them with theForeignmodule instead of asserting a concrete type. This moves validation from compile time to runtime, where it belongs. - Tag opaque values with newtypes. If a JavaScript library returns objects with prototype methods, wrap them in a newtype. This prevents accidental mixing with ordinary PureScript records, though it does not validate the object's shape.
The trade-off is verbosity. Runtime validation and wrapper functions add code that a purely trusted annotation would avoid. For well-known, stable APIs, a thin wrapper may be enough. For third-party libraries or data crossing a network boundary, the extra safety is usually worth the overhead.
An Actionable Check Before You Ship
Before wiring an FFI function into application logic, exercise it with boundary inputs in a scratch Node script. Pass the smallest and largest values your PureScript type allows, and verify the JavaScript result matches the promised shape. This is far cheaper than debugging a production crash caused by a type annotation the compiler happily accepted.
PureScript's type system is powerful, but it stops at the FFI file. Treat that boundary as untrusted, and your integrations will be safer for it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.