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