Programs as Data

"Any sufficiently advanced technology is indistinguishable from magic."

– Arthur C. Clarke


Most optic work is "update this nested field". Sometimes the problem is different: describe a sequence of optic operations as data, then decide later how to run it. The Free Monad DSL turns optic operations into a value you can pass around, inspect, and run under different strategies: production, audit, a checked run, or a test interpreter of your own. Interpreters are the strategies that turn the description into a result.

Here is the whole idea before any of the theory. One program, described once, run three different ways. Every line compiles and runs on every build, and a test holds each result:

    // A description, not an action: nothing has touched the account yet
    Free<OpticOpKind.Witness, Account> withdrawal = withdraw(account, new BigDecimal("30"));

    // Run it for real
    DirectOpticInterpreter direct = OpticInterpreters.direct();
    Account settled = direct.run(withdrawal);
    // Account[accountId=ACC-1, balance=70]

    // Run the same value again, recording every optic operation on the way
    LoggingOpticInterpreter logging = OpticInterpreters.logging();
    Account audited = logging.run(withdrawal);
    List<String> trail = logging.getLog();
    // one entry per optic operation the program performed

    // Or run it and get a report of what went wrong instead of the result
    ValidationOpticInterpreter validator = OpticInterpreters.validating();
    ValidationOpticInterpreter.ValidationResult check = validator.validate(withdrawal);
    boolean safeToRun = check.isValid();
    // true: no nulls written, no modifier threw

The account starts on 100 and the withdrawal is 30. Read Free<OpticOpKind.Witness, Account> as a program of optic operations that returns an Account when run; OpticOpKind.Witness is the witness that tags those operations. withdraw is the compiled example's own method, not library API.

validating() is a checked run, not a dry run

Despite the name, validate executes the program. Its own javadoc is explicit: operations are run so that flatMap chaining produces the right values, and the validation is collected alongside. A modify modifier is applied twice, once to check it and once to perform it. So it is safe for pure modifiers over immutable data, and unsafe for anything with a side effect.

For genuine inspection with nothing executed, use ProgramAnalyser.analyse(program), whose traversal is structural and never runs a step. Its counts are a lower bound: a step reached through flatMap, inside an error handler, or under an Ap node stays out of sight until the program runs. hasOpaqueRegions() reports only the flatMap continuations, so false does not mean every step was inspected.

Why this matters

The three blocks differ by one line. withdrawal is an ordinary value: it can be stored in a field, passed to a method, returned from one, and run later or never. That is the property the rest of this group trades on. An audit trail stops being logging statements scattered through the code and becomes a second interpreter over the same description; a structural analysis of what a program contains stops being guesswork and becomes a walk over the value.

If you have not yet hit a problem that needs this, you do not need this group. Come back when an audit requirement, a testability concern, or a multi-mode execution scenario forces the issue.


Pages in this group

Decision Trees picks the interpreter for what you want back from a program.

  1. Free Monad DSL: Optic programs as composable data
  2. Interpreters: Several execution strategies for one program

Hands-On Learning

Practise the Fluent API and the Free Monad DSL in the Fluent & Free DSL Journey (22 exercises).

Effect Handlers is the Effect Path equivalent: free-monad-style algebraic effects for computations rather than optics.


Previous: Auditing Complex Data Next: Free Monad DSL