PureScript FFI boundary design when migrating JavaScript modules incrementally
0 reputation · 27 Oct 2021, 10:14 UTC
0 reputation · 27 Oct 2021, 10:14 UTC
I am migrating a small browser application from plain JavaScript to PureScript one module at a time, and both sides must keep running together in production during the transition. The plan is to wrap the remaining JavaScript modules behind foreign import declarations so new PureScript code can call them, while legacy code paths still call the same JavaScript directly.
The unresolved decision is how to keep the FFI type declarations trustworthy while the JavaScript side is still changing. The compiler does not check that a foreign import signature matches the actual JavaScript function, so a drift between the two would only surface at runtime. I also need to decide whether effectful JavaScript calls should be typed as Effect immediately or wrapped in a narrower custom effect row during the migration.
Assume PureScript 0.15.x with spago builds, and that the output is bundled for the browser alongside the legacy bundle.
Effect directly, or a constrained effect row, to avoid locking in the wrong boundary early?34775 reputation · 27 Oct 2021, 20:06 UTC
There is no compiler check that a foreign import signature matches its JavaScript, so the established pattern is not a versioning mechanism but a discipline: keep each FFI module a thin alias layer, co-locate the .purs and .js files so they change in the same commit, and back every import with a contract test that runs against the real JavaScript in CI. Expose Effect directly for now, and make the public API a safe PureScript wrapper module rather than the raw FFI module, so you can tighten effect rows later without breaking callers.
What is confirmed: spago and the 0.15.x compiler typecheck the .purs side only. The accompanying .js FFI file is not typechecked at all — a syntax error there surfaces at bundle time or runtime. What follows is the standard mitigation, not a built-in feature.
foreign import should be a direct alias to one exported JS function, with no logic. All marshalling — converting Nullable/Undefined and raw records into ADTs — lives in a separate PureScript wrapper module. The untrustworthy surface is then one small file per JS module.spec or similar) that calls it with representative inputs and asserts on outputs, run via spago test against the real JS modules. Verify the setup works by deliberately breaking one signature and confirming the suite fails. This turns runtime drift into a CI failure.Foo.purs and Foo.js in the same directory, require both to change in the same PR, and put a header comment in the JS file listing its exported signatures as the source of truth. Also lint/build the JS side in CI, since the compiler never sees it.Use Effect. A bespoke effect label that nothing interprets adds noise while permitting exactly the same runtime behavior. The migration-safe move is structural, not type-level: because callers depend on your wrapper functions rather than the raw imports, you can later introduce a constrained row (e.g. a LEGACY or DOM label) behind those wrappers without changing call sites. Only invest in a custom row if you genuinely plan to interpret or restrict effects.
Importing the same JS module through the FFI and directly from legacy code is generally safe: bundlers like esbuild and webpack deduplicate ES modules resolved to the same file path, so both sides share module-level state — usually what you want mid-migration. Two real pitfalls:
One caveat to flag for review: foreign import data types carry no runtime representation guarantee, so if the JS side renames a field, PureScript record access fails only when that code path executes — another reason the contract tests should exercise real shapes, not just happy paths.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.