How do pure function call chains work and what restrictions apply?
← Purity Contracts · Ref: Q690
Pure functions can only call other pure functions. This transitive rule ensures the entire call chain is free of side effects.
TRANSITIVE PURITY (E08130)
If function A is pure and calls function B, then B must also be pure. If B calls C, then C must also be pure. The chain of purity is enforced transitively by the compiler.
MUTATION PREVENTION (E08120)
Pure functions cannot mutate any state:
- Cannot reassign fields (use := on a property) - Cannot use += on collections - Cannot call methods that mutate state
SEPARATE CHAINS
A class can have both pure and non-pure method chains. The pure chain reads state, the non-pure chain modifies it. They must not cross.
CORRECT PATTERN
transform() as pure calls validate() as pure calls normalize() as pure update() calls modify() calls save()
See Q560 for pure method basics. See Q566 for pure method restrictions. See Q678 for purity override contract.
Example
defines module qa.purity.callchain defines function <?- Pure function chain: each function calls only other pure functions. Compiler verifies the entire chain is pure. -?> normalize() as pure -> rawText as String <- cleaned as String: rawText.trim() validate() as pure -> inputText as String <- isValid as Boolean: false normalized <- normalize(inputText) isValid: normalized? and (length normalized > 0) transform() as pure -> sourceText as String <- result as String: "" if validate(sourceText) result: normalize(sourceText).upperCase() defines class <?- Class with separate pure and non-pure method chains. Pure chain reads state, non-pure chain modifies state. -?> TextProcessor entries <- List() of String TextProcessor() -> initialEntry as String entries += initialEntry //Pure chain: read-only currentCount() as pure <- rtn as Integer: length entries findFirst() as pure <- rtn as String: "" firstEntry <- entries.getOrDefault(0, "") if firstEntry? rtn: transform(firstEntry) //Non-pure chain: modifies state addEntry() -> newEntry as String if validate(newEntry) entries += normalize(newEntry) default operator ? defines program PureCallChainDemo() stdout <- Stdout() stdout.println(transform(" hello world ")) stdout.println(`Valid: ${validate("test")}`) stdout.println(`Valid empty: ${validate("")}`) processor <- TextProcessor("first entry") processor.addEntry(" second ") stdout.println(`Count: ${processor.currentCount()}`)
Common mistakes
E08120 — Pure methods cannot mutate state. Using '+=' on a collection field inside a pure method triggers E08120. See ek9 -h E08120 for details.
Incorrect:
currentCount() as pure <- rtn as Integer: 0 entries += "extra" rtn: length entries
Correct:
currentCount() as pure <- rtn as Integer: length entries
E08130 — A pure method cannot call a non-pure method. Since addEntry() modifies state, calling it from a pure method triggers E08130. The entire pure call chain must consist of pure functions only. See ek9 -h E08130 for details.
Incorrect:
findFirst() as pure <- rtn as String: "" addEntry("injected") firstEntry <- entries.getOrDefault(0, "") if firstEntry? rtn: transform(firstEntry)
Correct:
findFirst() as pure <- rtn as String: "" firstEntry <- entries.getOrDefault(0, "") if firstEntry? rtn: transform(firstEntry)
Other ways to ask this
- Why can't pure functions use mutation operators?
- How does EK9 enforce transitive purity in call chains?
- Can pure functions call other pure functions?
- How does the transitive purity rule work?
Coming from another language?
Java: no purity enforcement. Kotlin: no purity. Rust: shared references prevent mutation. Python: no purity. Haskell: IO monad separates pure from impure. EK9: 'as pure' keyword with transitive compile-time enforcement.
Keywords: function, side-effect, contract, transitive, purity, call, E08130, mutation, E08120, immutable, pure, restriction, chain