Blog › ICP guides

Frege developer on retainer: Haskell-on-JVM, pure native vs native declarations, IO monad on the Java platform, mutable Java object binding, and Frege type-system correctness on monthly retainer

October 1, 2026 · ~15 min read

A Frege developer was building a library that consumed a Java Iterator<String> to process records from a database result set. They wrote native bindings to call the Java iterator methods from Frege code: pure native hasNext :: MutableIterator -> Bool and pure native nextElement "next" :: MutableIterator -> String. The pure native declaration tells Frege the Java method is referentially transparent — calling it with the same argument always produces the same result. Frege is free to eliminate duplicate calls, reorder calls, or cache the result. When the developer called nextElement it twice in the same expression — expecting to advance the iterator and retrieve two distinct strings — both calls returned the same string. The Java Iterator.next() method advances mutable state on each call; two calls to nextElement it with the same it argument represent the same referentially-transparent application, and Frege’s compiler reduced them to one call with the result shared. Wrong values: 2 per pair of consecutive reads. The developer changed the declarations to native (effectful) and wrapped them in IO: native hasNext :: MutableIterator -> IO Bool and native nextElement "next" :: MutableIterator -> IO String. Then restructured the call site to sequence the calls explicitly in the IO monad with >>= and do-notation. Wrong values: 2 per consecutive read → 0.

The work log entry read “reclassified hasNext and nextElement from pure native to native with IO return types, restructured call sites to use do-notation, 7h.” It names the result and the duration. It cannot explain why pure native causes incorrect values when applied to mutable Java methods: a pure native declaration is a promise to Frege’s optimizer that the function is referentially transparent; referential transparency means that replacing any expression with its value preserves program meaning; Frege’s compiler, upon seeing two syntactically identical function applications with the same argument, is entitled to compute the result once and share it across both use sites — this is the same optimization that any Haskell compiler applies to pure expressions via the sharing and common subexpression elimination transformations. The Java Iterator.next() method is not referentially transparent: it advances a mutable pointer inside the iterator object and returns the element at the new position; calling it twice with the same iterator reference returns two different values. When Frege sees nextElement it twice, the compiler does not know that the underlying Java method mutates state; it was told, via pure native, that it does not; it therefore reduces the two applications to one and shares the result. The developer sees the same string printed twice instead of two distinct strings. No exception is thrown. No diagnostic is produced. The JVM executes exactly what the Frege compiler generated; the fault is in the semantic classification, not in any execution mechanism.

It cannot explain why the fix requires both reclassification and call-site restructuring: changing pure native nextElement "next" :: MutableIterator -> String to native nextElement "next" :: MutableIterator -> IO String changes the type of nextElement it from String to IO String; code that previously bound nextElement it to a String variable now does not type-check, because IO String is an action in the IO monad, not a plain String; every call site must be updated to bind the result inside an IO action using <- in do-notation or >>=; the surrounding function must itself return an IO type to accommodate the monadic binding; if the calling function was previously pure, it must be reclassified as IO-returning; this reclassification propagates up the call chain until it reaches a function that was already in IO (typically main or a top-level IO action). The type system enforces this propagation: you cannot call an IO String action from a pure context; the only escape is unsafePerformIO, which is semantically dangerous on the JVM for reasons described below. The restructuring effort depends on how deeply into the codebase the erroneous pure native declaration was used; in a library that threaded the iterator through multiple layers of processing, the reclassification can require updating a dozen functions. The seven hours of purity analysis, type-error propagation tracing, and call-site restructuring are invisible in a diff showing two keyword changes and a handful of function signatures.

Frege native declarations: pure native vs native, IO monad on the JVM, and the purity assumption

Frege, created by Ingo Wechsung (initially released around 2012), is Haskell on the JVM. Frege source code compiles to Java source code — one .java file per Frege module — which is then compiled to JVM bytecode by javac. The resulting .class files run on any standard JVM and can be called from Java code and vice versa. Frege’s type system is Haskell-compatible: algebraic data types declared with data, type classes declared with class, type class instances with instance, newtype wrappers with newtype, type aliases with type, polymorphic type inference (Hindley-Milner extended with type classes), and the IO monad as the mechanism for sequencing side effects. Frege is lazy by default for data constructors but strict by default for function arguments — an important difference from Haskell, discussed below. The language’s central value proposition is access to the entire Java ecosystem (the standard library, third-party JAR files, JVM tooling) from a language with Haskell’s type safety and purity guarantees.

Native declarations are the mechanism through which Frege binds identifiers to Java methods, constructors, and fields. The syntax distinguishes two kinds: pure native and native. A pure native declaration such as pure native abs :: Int -> Int declares that the Java method is referentially transparent — given the same input, it always returns the same output, with no observable side effects and no dependence on mutable state. Frege’s compiler treats pure native functions identically to functions defined in Frege itself: they participate in common subexpression elimination, sharing, and inlining. A correct use of pure native is a method like Math.abs or String.length: deterministic, stateless, same output for same input on any JVM. An incorrect use is any method that reads or writes mutable object state, reads external state (system clock, file system, network), throws exceptions dependent on runtime conditions, or has any other effectful behavior. The compiler cannot verify the purity claim; it trusts the declaration.

A native declaration with an IO return type declares that the Java method has side effects and must be sequenced through the IO monad. native nextElement "next" :: MutableIterator -> IO String means: calling nextElement it produces an IO String action; executing that action in the IO monad calls it.next() on the JVM and yields the returned string. Because the type of the action is IO String rather than String, the Frege type system prevents the action from being used in a pure context; it must be bound with <- in a do-block or composed with >>=. Two consecutive <- bindings in a do-block are sequenced: the first action runs, its result is bound, then the second action runs. This sequencing guarantee is the property that makes effectful Java calls correct: Frege’s IO monad is not a library convention; it is enforced by the type system and the compiler’s code generation, which outputs Java code that evaluates IO actions in declaration order.

Pure native field access uses a dot-notation string to identify the Java field: pure native fieldName ".fieldName" :: ObjectType -> FieldType. This is appropriate for immutable fields (Java final fields set at construction time and never modified). Mutable fields — those set by non-constructor methods — require a native accessor returning an IO type: native getSize ".size" :: MutableList -> IO Int. Constructors are declared with native new: native new :: String -> IO MutableFoo for a Java constructor that takes a String. The IO return type on constructor declarations models the fact that Java object construction creates a new object with its own mutable identity; the same call to new MutableFoo "argument" does not return the same object twice in any meaningful sense, so it is not referentially transparent.

Frege’s Mutable s a type is the type-level mechanism for tracking mutable Java objects. An object of type Mutable s ObjType is a mutable Java object of Java class ObjType, tracked in a state thread identified by the phantom type parameter s. This mirrors Haskell’s STRef s a pattern from the ST monad: the state thread s prevents mutable objects created in one IO or ST context from escaping into a different context. STMutable s ObjType is the ST monad analogue, used for local mutation that does not escape to IO and can be safely run as a pure computation using ST.run. When developers wrap Java classes, they should use Mutable s ClassName as the type for any Java object that has mutable state, rather than a bare type alias for the Java class. Forgetting the Mutable wrapper is one of the most common sources of incorrectness in Frege native declarations: without the wrapper, the type system treats the Java object as a pure value, allowing the compiler to share and cache it, exactly as with the iterator example above.

Frege’s standard library includes wrappers for common Java library classes in the frege.java.lang and frege.java.util packages. frege.java.lang.String, frege.java.lang.Integer, frege.java.util.List, and related types are already wrapped with appropriate pure native or native IO declarations. Developers wrapping their own Java classes can consult these standard wrappers as canonical examples. A native class declaration wraps an entire Java class, declaring its methods inline: data MyClass = pure native my.package.MyClass where { pure native myPureMethod :: MyClass -> Int; native myEffectfulMethod :: MyClass -> IO () }. The throws keyword in a native declaration tells Frege that the Java method may throw a checked exception: native readLine :: BufferedReader -> IO String throws IOException. The exception type must be a subtype of java.lang.Throwable and must be caught in the calling IO action; Frege’s IO monad propagates uncaught Java exceptions as IO-level failures.

unsafePerformIO exists in Frege but is particularly hazardous on the JVM. In Haskell, unsafePerformIO extracts a value from an IO action without sequencing — it is unsafe because it breaks referential transparency, but in practice GHC’s compilation model makes the timing of the unsafe execution somewhat predictable for certain patterns. On the JVM, the JIT compiler performs aggressive instruction reordering, speculative execution, and result caching independently of the Frege compiler’s output. A Java method wrapped with unsafePerformIO may be executed at a time the developer does not expect, may have its result cached by the JIT, or may be reordered relative to other IO actions. The Frege documentation explicitly warns against using unsafePerformIO except in very controlled circumstances (wrapping truly referentially transparent Java methods that should have been declared pure native but could not be for technical reasons). In retainer work, encountering unsafePerformIO is usually a sign that an earlier developer reached for it as a workaround when the type system rejected their code due to incorrect pure native declarations; the correct fix is to reclassify the declarations, not to use the unsafe escape hatch.

Frege type system, Haskell compatibility, and typical retainer work

Frege’s type system is modeled on Haskell 2010. Algebraic data types are declared with data: data Tree a = Leaf | Node (Tree a) a (Tree a). Type aliases are declared with type: type Name = String. Newtype wrappers are declared with newtype: newtype Wrapper a = Wrapper { unwrap :: a }. Type classes are declared with class: class Container f where { empty :: f a; insert :: a -> f a -> f a }. Type class instances are declared with instance: instance Container [] where { empty = []; insert = (:) }. The deriving mechanism works for Eq, Ord, Show, Enum, and Bounded, similar to Haskell. Type inference uses Hindley-Milner with type class constraints; explicit type signatures are optional but recommended for top-level definitions.

A key difference between Frege and Haskell is the default evaluation strategy for function arguments. Haskell evaluates function arguments lazily by default (non-strict semantics): arguments are not evaluated until their values are needed. Frege evaluates function arguments strictly by default: when a function is applied to an argument, the argument expression is evaluated to weak head normal form before the function body executes. Laziness in Frege is explicit: the Lazy a type wraps a value in a lazy thunk, and () thunks (unit-argument functions) can be used to defer evaluation. This strictness-by-default design was a deliberate choice to align with the JVM’s execution model: the JVM performs poorly with large numbers of unevaluated thunk objects allocated on the heap, and lazy-by-default Haskell programs often require explicit seq and bang-pattern annotations to avoid space leaks; Frege avoids this class of problem by being strict by default at the function argument level. Data constructors in Frege are still non-strict: fields of a data constructor are not forced until accessed, which enables recursive data structures and limited lazy data pipelines.

Pattern matching in Frege is Haskell-compatible: function definitions use multiple equations with pattern matching on the left-hand side, guards (| condition = expression), and where clauses for local definitions. case expressions, let expressions, list comprehensions, and do-notation all work as in Haskell 2010. The do-notation in Frege desugars to >>= and >> as in Haskell: do { x <- action1; action2 x } desugars to action1 >>= \x -> action2 x. Any monad can be used with do-notation, not just IO; the Monad type class in Frege is the same as Haskell’s. Higher-kinded types, multi-parameter type classes, and functional dependencies are supported. Frege does not support all of Haskell’s GHC-specific language extensions, but the core Haskell 2010 feature set is well-covered.

The compilation pipeline is: Frege source files (extension .fr) are compiled by the Frege compiler (fregec.jar) to Java source files (.java); each Frege module produces one .java file in a directory structure matching the module’s package path; the Java source files are compiled by javac to .class files; the Frege compiler also produces .fr interface files (analogous to Haskell’s .hi interface files) that record the module’s exported types and definitions for use by importing modules. The Frege module system uses Haskell-style module declarations: module my.package.ModuleName where. The module name must match the directory and file path: my/package/ModuleName.fr containing module my.package.ModuleName where produces my/package/ModuleName.java. Mismatches between the module declaration and the file path cause compilation errors that can be confusing to developers unfamiliar with the dual Java and Frege naming conventions.

Build system integration is a significant part of Frege retainer work. The frege-maven-plugin integrates Frege compilation into a Maven build: it invokes fregec on Frege source files before the javac compilation phase, placing the generated Java source in the Maven target directory where javac can find them. Configuration requires specifying the Frege source directory, the fregec.jar path, and the module path. Gradle integration uses a custom task or a community Gradle plugin; the task invokes the Frege compiler as a Java process, passing the appropriate classpath and source directories. Both build systems require that the Frege compiler’s output directory be on the javac source path; if the generated Java files are not found by javac, the build fails with cryptic class-not-found errors that do not mention Frege at all. Diagnosing these build failures requires understanding both Maven’s or Gradle’s source set model and the Frege compiler’s output conventions — expertise that sits at the boundary of two distinct build system knowledge bases.

Typical Frege retainer work falls into several categories. Native declaration work: auditing existing pure native declarations to verify that the declared Java methods are genuinely referentially transparent; reclassifying incorrect declarations to native with IO return types; writing new native class declarations for third-party Java libraries; adding throws annotations for methods that throw checked exceptions; designing Mutable s ObjType wrappers for mutable Java objects. IO sequencing work: restructuring code that calls effectful native methods outside monadic context; threading IO through function chains that were written assuming pure access to Java methods; converting sequential data processing pipelines to use do-notation or monad combinators. Type system correctness work: diagnosing type errors caused by missing Mutable annotations; resolving type class instance conflicts between Frege and Java type hierarchies; fixing deriving failures for types that contain native Java types. Build system work: configuring frege-maven-plugin for a Maven multi-module project; aligning Frege module declarations with JVM package paths; resolving classpath issues when Frege modules depend on third-party JARs; debugging incremental build failures where stale Frege interface files (.fr) cause incorrect type errors.

The JVM execution model introduces several Frege-specific concerns that have no direct Haskell equivalent. JVM class loading is lazy: a class is loaded the first time it is referenced, which can cause NoClassDefFoundError or ClassNotFoundException at unexpected runtime points — in Frege code, this typically surfaces when a native declaration references a class that is not on the classpath at runtime, even if it was present at compile time. JVM generics use type erasure: Java’s List<String> and List<Integer> are both List at runtime; Frege native declarations that distinguish these at the Frege type level produce code that relies on JVM heap corruption prevention mechanisms (such as checked casts and runtime type tokens) to enforce type safety at the boundary. JVM JIT compilation can reorder memory accesses across thread boundaries unless Java’s memory model volatility or synchronization mechanisms are in place; Frege code that uses native Java objects shared between threads requires Java-side synchronization or volatile declarations that Frege’s type system does not enforce.

How HourTab tracks Frege developer retainer hours

Frege retainer work carries the invisible-hours problem in an especially acute form: the bug that opened this post — wrong values from duplicated pure-native calls — produces no error, no exception, no stack trace, and no compile-time warning. The iterator returns the same string twice instead of two different strings. In a database processing pipeline, this means two records in the output contain the same data when they should contain different data. The symptom might not be noticed immediately if the pipeline processes thousands of records and the duplicate is buried in the output; it might be noticed as a data quality issue long after the code was written; it might be attributed initially to a bug in the database query, the result set mapping, or the downstream consumer, not in the Frege-Java interop layer.

Diagnosing the bug required: first, confirming that the database query and result set were correct by inspecting the raw JDBC output outside Frege; second, isolating the Frege layer by testing the native bindings in isolation with a simple in-memory iterator and verifying that the duplicate-value symptom reproduced without the database; third, recognizing that the duplicate came from sharing — two syntactically identical applications of nextElement it with the same argument it — rather than from the iterator’s state machine or the JDBC driver; fourth, understanding that Frege’s pure native declaration gives the compiler explicit license to perform the sharing optimization that caused the duplication; fifth, verifying this by inspecting the generated Java source and confirming that the Frege compiler had in fact emitted code that called it.next() only once and assigned the result to a local variable used in both positions; sixth, identifying the correct fix (reclassify to native with IO) and understanding why it works (the IO monad’s sequencing guarantees that each monadic bind executes the action once and does not share results across separate binds); seventh, propagating the IO reclassification up the call chain and restructuring every call site.

Steps three through five require knowledge that spans both Frege’s optimization model (compiler sharing semantics for pure native) and the JVM execution model (that Iterator.next() mutates the iterator cursor). Neither piece of knowledge alone is sufficient; a developer who knows Haskell deeply but has not worked with Frege’s native declarations may not know that the pure native declaration is the mechanism that enables sharing; a Java developer who knows iterator semantics perfectly may not know that a pure annotation in a functional language gives the compiler optimization rights over call duplication. The retainer developer must hold both simultaneously to recognize the cause.

HourTab gives Frege developers a public retainer-hours URL they send to clients — typically teams building JVM applications that require Haskell-style type safety, researchers using Frege as an academic functional programming platform on the JVM, and engineers integrating Frege modules into existing Java or Scala codebases. For Frege retainers, each work log entry should name the mechanism at the level of the native declaration analysis: which Java method was declared incorrectly, what kind of side effect it had (mutable state, exception, I/O), how the incorrect declaration caused incorrect behavior (sharing, caching, reordering), and what the correct declaration and call-site restructuring looked like. Comparative context for scope discussions: Frege retainer work is related to but distinct from Eff developer retainers (Eff focuses on algebraic effects and handlers with a different effect tracking model; no JVM or Java interop concerns) and Effekt developer retainers (Effekt uses second-class capabilities and lexical effect scoping on the JVM, making it a related JVM functional language, but with a different effect system and no Haskell type-class compatibility layer); a Haskell developer retainer would be the closest related engagement for pure type-system and monad work, but Frege’s native declaration correctness and JVM build system integration have no direct Haskell equivalent.

HourTab’s work log format for Frege retainers makes the native declaration auditing, IO propagation tracing, and JVM interop debugging visible to clients who would otherwise see only the symptom — wrong values in an output file — and not understand why the fix required: inspecting generated Java source to confirm that sharing was applied, tracing the propagation of the IO reclassification through the call chain, restructuring every call site from direct application to monadic binding, verifying that the corrected code produced correct values for both the in-memory iterator test and the full database pipeline, and documenting which other native declarations in the codebase might be similarly misclassified. The log entry “reclassified iterator native declarations from pure native to IO native, restructured call sites, 7h” is correct as a time record; it is incomplete as a value record. HourTab’s categorized log format — native declaration category (method: Iterator.next(); original: pure native; correct: native IO; evidence: generated Java confirms single call with shared result; fix: reclassification applied), IO category (functions requiring IO threading: 4; call sites restructured: 9; do-notation bindings added: 9; type errors resolved during propagation: 11), mutable type category (type: MutableIterator; Mutable wrapping: confirmed present; sharing prevented by IO sequencing after reclassification) — makes the diagnostic path visible and gives clients who funded the work a record of what was actually investigated and why seven hours was a reasonable duration for two keyword changes and a dozen function signatures.

Track Frege developer retainer hours without the status emails

HourTab gives Frege developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your Java interop audit log — pure native vs native diagnosis, IO monad propagation tracing, mutable object binding analysis, JVM build system debugging — becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: Frege developer retainers

What does a Frege developer on retainer typically do?

A Frege developer on monthly retainer covers native declaration correctness (pure native for referentially transparent Java methods; native with IO return type for effectful Java methods; choosing the correct declaration is a semantic decision not enforced by the Java or Frege compiler), IO monad sequencing on the JVM (sequencing effectful Java calls via >>= and do-notation in the IO monad; IO String vs String distinctions; running IO actions from main; lifting pure values with return), mutable Java object binding (MutableNative type annotations; Mutable s ObjType type-level tracking of mutable state; STMutable for ST monad usage; preventing the type system from treating mutable Java objects as pure values), Java interop patterns (native class declarations to wrap entire Java classes; native field access with dot-notation strings; throws keyword for methods throwing Java exceptions; catching Java exceptions as Throwable in IO), type class instances for Java-wrapped types (Eq, Ord, Show instances for native types), and build system integration (Frege compiler invocation from Maven via frege-maven-plugin; Gradle integration; module path alignment between Frege module declarations and JVM package structure).

What Frege work is most commonly underlogged in a retainer?

Pure-native correctness audits (systematically reviewing all pure native declarations in a codebase to verify that the declared Java methods are actually referentially transparent; identifying which methods mutate object state, throw exceptions, or read system time; reclassifying incorrect declarations to native with IO; 6–10 hrs invisible); IO sequencing restructuring (converting call sites that call native IO actions outside a monadic context; threading IO through function chains that were written assuming pure access; restructuring data pipelines that read from Java iterators or streams to use explicit IO sequencing; 5–9 hrs invisible); MutableNative type repair (identifying where Mutable s ObjType or STMutable s ObjType should replace raw native types; fixing type errors caused by passing mutable objects through pure function boundaries; 4–7 hrs invisible); Java exception propagation (tracing how Java exceptions thrown from native methods surface in Frege IO actions; adding catches and exception handlers; verifying that Throwable subtypes are caught correctly; 3–6 hrs invisible); build system alignment (fixing module path mismatches between module my.Module where declarations and the JVM package structure expected by Maven or Gradle; 3–5 hrs invisible).

What are typical Frege developer retainer rates?

Entry-level Frege developers (1–2 years, basic Haskell-style type system, ADTs, IO monad, simple Java interop) bill at $65–$120/hr. Mid-level Frege programmers (2–4 years, pure native vs native correctness, MutableNative types, Java exception propagation, do-notation IO sequencing) bill at $100–$180/hr. Senior Frege JVM integration developers (4+ years, advanced native class wrapping, Frege compiler internals, Maven and Gradle build system integration, STMutable patterns, deep Java interop library design) bill at $150–$270/hr. Monthly retainer ranges: $2,000–$3,800/mo advisory (15–25 hrs), $5,000–$13,000/mo for full Frege and JVM integration engineering.

What should a Frege developer retainer agreement include?

A Frege developer retainer agreement should specify: native declaration scope (which Java classes are wrapped; which methods are declared pure native vs native; criteria for reclassifying declarations when Java method semantics change; whether the engagement covers auditing existing native declarations or writing new ones); IO sequencing scope (which functions are IO-returning; whether existing code needs to be restructured to thread IO through; which call sites invoke Java iterators, streams, or other stateful objects requiring monadic sequencing); mutable object scope (which Java types are mutable and require Mutable s ObjType wrapping; whether STMutable patterns are used for local mutation with the ST monad; how mutable objects are passed across module boundaries); Java exception scope (which Java methods are declared with throws; how Throwable subtypes are caught and handled in IO; whether unchecked exceptions from native calls are handled); build system scope (Maven or Gradle; whether Frege module declarations are aligned with JVM package structure; whether frege-maven-plugin or a custom Gradle task is used); and hour logging format (declaration: method name, pure native vs native classification, whether reclassification was needed; IO: which IO actions were sequenced, which call sites were restructured; mutable: which types needed Mutable wrapping, which type errors were caused by missing annotations).

How should Frege developer retainer hours be logged?

Log each Frege retainer session with: native declaration category (method name in Java; Frege identifier; original classification: pure native or native; correct classification: pure vs effectful; evidence of incorrectness: duplicate call sharing, wrong values, non-deterministic results; reclassification applied; before/after incorrect-value rate per call site); IO sequencing category (function name; original signature without IO; revised signature with IO return type; which call sites required restructuring to use >>= or do-notation; whether the IO was threaded up through a call chain or isolated to a single function; number of call sites updated); mutable type category (Java class name; original native type used; correct Mutable s ObjType or STMutable s ObjType annotation; which pure function boundaries were violated by passing the mutable object; which type errors surfaced after adding Mutable wrapping; before/after type-check status); Java exception category (Java method name; exception type thrown; Frege throws declaration added; catch handler written in IO; which Throwable subtypes were caught; before/after unhandled-exception rate); and build system category (module path: Frege module declaration vs JVM package path; discrepancy found; fix applied to frege-maven-plugin configuration or Gradle task; before/after compilation success rate).