Algebraic Effects in Quartz
Epic: Operation Piezoelectric Effects (commit tag:
[piezo]) Status: Phase 0 design complete (2026-04-18). Phase 1 implementation pending. Living plan:docs/research/EFFECTS_IMPLEMENTATION_PLAN.mdResearch notes:docs/research/EFFECT_SYSTEMS_NOTES.mdImplementation strategy:docs/research/EFFECTS_LLVM_COMPILATION_MEMO.md
Named after the piezoelectric effect (Curie, 1880) — the physical property by which quartz crystals convert mechanical stress into electric charge. Quartz is the canonical piezoelectric material; quartz oscillators are what make computers tick. This epic makes the programming language Quartz capable of the same transformation: converting user code into observable, catchable, composable effects.
This document is the canonical guide to Quartz’s algebraic effect system. It defines what effects are, how they integrate with the rest of the language, and how to use them — at every level of engagement.
It is a stub: Phase 1 expands §3–§6 with full spec, §7 with examples, §8 with error-message reference. Current content reflects the design decisions committed in Phase 0 and the governing philosophy.
1. Philosophy: Invisible by Default, Rich When Wielded
Quartz is the first systems language with first-class algebraic effects. But you wouldn’t know it from looking at a trivial program:
def main()
puts("hello, world")
end
No effect annotations. No ceremony. This program works — and is effect-correct under the type system, which knows puts uses StdIo and that main’s missing annotation means “whatever the prelude’s default handler installs.” The effect is there — it’s invisible.
Quartz supports three engagement levels, all first-class:
| Level | What you write | Who lives here |
|---|---|---|
| Invisible | No can. No try. No with. Code reads as if effects don’t exist. | Beginners, quick scripts, systems code that trusts main-defaults |
| Implicit | Effects inferred; visible in LSP hover, quartz doc, error messages. Code stays clean. | Library authors wanting internal consistency without ceremony |
| Explicit | Full can rows, with handlers, try markers, effect-polymorphic combinators, sandboxing. | Sandboxing, async composition, rigorous APIs, advanced code |
Non-negotiables
- Trivial programs have zero effect ceremony.
canannotations are never required for correctness — inference fills them in. They document or constrain.- Rich features (
with catch, row polymorphism,trymarkers, effect-polymorphicmap) are first-class, reachable from any scope — not gated by mode switches or pragmas. - Writing invisibly never locks you out of wielding. Drop into explicit at any expression boundary.
- Effects are substrate, not “advanced mode.” The rules about visibility are about noise, not capability.
How this fits Quartz’s broader design pattern
This matches patterns you’ll recognize across the language:
- Const by default,
varto opt in - Types inferred everywhere; annotate at boundaries
- Types rich at compile time, erased at runtime
- Implicit
itin blocks, explicit parameters when you want them - Open UFCS: three levels of method dispatch (impl method → trait method → open UFCS)
- Async colorless — no coloring tax
- Drop/RAII automatic; custom
Dropfor cleanup
Effects extend the pattern to error handling, Io, state, diagnostics, allocator, async — the substrate that makes Quartz’s runtime work.
2. What is an Effect?
TBD in Phase 1. This section will cover: the intuition (“an effect is a capability a function may exercise”), the contrast with monads, the contrast with exceptions, the formal definition (row-typed effect labels with operations), worked examples.
3. Syntax
TBD in Phase 1. Stub placeholders for the committed syntax:
3.1 Effect declarations
effect Throws<E>
def throw(e: E): Never
end
effect Log
def log(msg: String): Void
end
3.2 Effect rows on function signatures
def read_config(path: String): Config can FileIo, Throws<ConfigError>
def greet(name: String): Void can StdIo
def pure_fn(x: Int): Int # no row = pure (empty effect row)
Row polymorphism with tail variables:
def map<T, U, ε>(items: Vec<T>, f: Fn(T) can ε: U): Vec<U> can ε
# map has the same effects as f, whatever f's effects are
3.3 Handler installation
result = with catch (e: ConfigError) -> default_config() do ->
read_config(path)
end
3.4 The try prefix keyword (optional)
# These are equivalent; `try` is an explicit propagation marker.
value = parse_int(s) # implicit propagation via row inference
value = try parse_int(s) # explicit — reader sees the propagation point
3.5 The reify block (effect → Result conversion)
res: Result<Int, ParseError> = reify { parse_int(input) }
# reify catches any throws and wraps as Err; returns Ok on normal exit
4. The Blessed Effect Set (v6.0)
Quartz v6.0 ships 14 atomic effects + 1 row alias as first-class stdlib effects:
4.1 Phase 1 effects (shipped with v6.0 alpha)
| Effect | Role | Default handler |
|---|---|---|
Throws<E> | Control-flow errors | Uncaught throw → reach main → panic |
Panic | Unrecoverable errors | Print trace + flush + exit 101 |
Log | Structured logging | Write to stderr (with severity levels) |
FileIo | Disk I/O | Call through to filesystem syscalls |
NetIo | Network | Call through to socket syscalls |
ProcIo | Process spawn | Call through to fork/exec |
StdIo | stdin/stdout/stderr | Line-buf TTY, block-buf pipe, flush on panic |
Clock | Time queries, sleep | Call through to gettimeofday / nanosleep |
Random | RNG | Call through to getrandom / arc4random |
Env | Environment variables | Call through to getenv/setenv |
4.2 Phase 2 effects
| Effect | Role |
|---|---|
State<S> | Mutable threaded state |
Reader<R> | Config / dependency injection |
Alloc | Allocator (arena-swap, sandbox, test harness) |
4.3 Phase 3 effects
| Effect | Role |
|---|---|
Async | Async/await/go/channels (migrated from special-cased MIR lowering) |
4.4 Deferred / not blessed
Ndet— multi-shot non-determinism / backtracking. Deferred indefinitely. Single-shot compilation path is exclusive.Yield<T>— generators as effect. Deferred. Generators remain compile-time state-machine lowering.Debug/Trace— subsumed underLogwith severity levels.Exit— usePanicorThrows<ExitRequested>.
4.5 Row alias
Io = { FileIo, NetIo, ProcIo, StdIo, Clock, Random, Env }
can Io in a signature is sugar for the row above. Use when precise capability isn’t needed; reach for atomic labels when it is (sandboxing, security audit).
5. Error Handling — the Four Tiers
Quartz has exactly four tiers for “this could fail”:
| Tier | Semantics | Catchable? | Typed? | Canonical form |
|---|---|---|---|---|
| Assertion | ”I proved this; crash if wrong” | No (panic) | No | x! (postfix !) |
| Control flow (effect) | “Failure is a legit outcome for my caller” | Yes (with catch) | can Throws<E> | try x, throw(e) |
| Error as data | ”I want the error value to store/pass/serialize” | N/A (just data) | Return type | Result<T, E> |
| Panic | ”Unrecoverable (OOM, invariant violated)“ | No | No | panic(msg) |
5.1 Intent-to-form mapping
| Intent | Form |
|---|---|
| Test (“is it Some?”) | x.some? / x.ok? |
| Narrow (“if Some, give value”) | x is Some(v), if let Some(v) = x |
| Destructure (“do different things per variant”) | match / if let |
| Assert (“I’m sure; crash if wrong”) | x! |
| Propagate (“failure is my caller’s problem”) | try x (optional; implicit also works) |
| Default (“use this if absent”) | x ?? default, x.unwrap_or(d) |
| Chain (“short-circuit to None”) | a?.b?.c |
| Transform (“lift operation inside”) | x.map(f), x.flat_map(f) |
| Reify (effect → Result) | reify { expr } |
5.2 Deleted / retired forms
$try(x)macro — replaced bytryprefix keyword. One form for propagation.
5.3 Why ! isn’t effect-typed
! is an assertion. Writing opt! means “I’ve established this is Some; if I’m wrong, that’s a bug, and bugs should crash loud, not become catchable control flow.” Matching Rust’s .unwrap(). Three distinct intents keep three distinct syntactic forms.
6. Handler Semantics
TBD in Phase 1. This section will cover:
- Handler installation and the
with ... do ... endblock - How handlers form a stack; scoped (duplicate) labels allowed per our Model B commitment
- Handler pipelining and composition
resumesemantics (invoke the continuation with a value, or don’t)- Tail-resumption optimization (when an operation clause ends with
resume(e)andresumedoesn’t appear ine) - Handler return clauses
- Nested handlers and label shadowing
- Interaction with
panic(unwinds pastcatchhandlers; can be intercepted via thePanicdefault-handler swap)
7. Examples
TBD in Phase 1. Target: 10–15 curated examples in examples/effects/ covering:
- Hello-world with zero ceremony (invisible level)
- Throws basics — parse with error propagation
- Catch handler with default value
- Testing with swapped
Random/Clockhandlers - Sandbox with denied
NetIo - Effect-polymorphic
map - Composing multiple effects in one handler
reifyfor effect-to-Result conversion- The
Envaudit handler (security-software use case) - Reader+State for CLI config (Phase 2 pilot preview)
8. Main and the Default Handler Stack (§8.5)
Every Quartz program with a main() entry point has a prelude-installed default handler stack. Trivial programs never see it; the machinery is transparent.
Default handlers, composed outermost-to-innermost:
Panic→ print structured message + trace + flush stdout/stderr +exit(101)Throws<_>→ any uncaught throw reaches main and converts to a panic with a “tried to throw, nobody handled it” message + the throw’s value + stack trace + suggested remediation (with catchor row propagation)Log→ write to stderr with severity levelsStdIo→ line-buffered for TTY, block-buffered for pipe, auto-flush viaPanichandlerFileIo,NetIo,ProcIo,Clock,Random,Env→ pass-through to syscalls
Custom handlers installed via with ... do ... end are strictly additive overrides — they shadow the defaults within their dynamic scope. On scope exit, the default handler is restored.
Why this matters
This is the mechanism that makes “invisible level” work. Without default handlers, every main() would need explicit ceremony for every effect the program uses. With them, def main(); puts("hi"); end is a complete, correct program.
9. Error Messages
TBD in Phase 1. Full reference with sample messages for every error category.
Phase 1 quality bar (committed):
- Point at offending source location (file:line:col, carets).
- Plain-English description first, row-syntax second.
- Always suggest a concrete compilable fix.
- Grade by engagement level — invisible users never see “row,” “label,” “unification” vocabulary.
- Never leak implementation vocabulary.
- Runtime uncaught-throw formats with trace + remediation hint.
See EFFECTS_IMPLEMENTATION_PLAN.md § “Error messages (quality bar)” for the full spec and engagement-level detection heuristic.
10. Compilation Model (implementer reference)
Committed: evidence-passing (Xie & Leijen 2020 style). Full details in docs/research/EFFECTS_LLVM_COMPILATION_MEMO.md.
Evidence is a threaded parameter: a singly-linked list of (marker, handler_ptr) nodes carried as an extra parameter on every function with a non-empty effect row. Pure functions take no evidence parameter and keep direct-call performance.
Three operation kinds determine the compilation path for each effect op:
| Kind | When | Compilation | Perf |
|---|---|---|---|
value | Op always resumes with a constant | Handler field lookup | O(1) |
function | Op is tail-resumptive | Direct indirect call through handler vtable | O(depth) lookup + 1 indirect call |
operation | Op is general (may abort, resume, or deferred multi-shot) | Reifies continuation via existing $poll state machine machinery | CPS cost, existing async overhead |
>90% of Quartz effect ops are function-kind (Log, StdIo, FileIo, NetIo, ProcIo, Clock, Random, Env, State.get/set, Reader.ask, Alloc.alloc). Slow-path operation kind is only: Throws.throw (aborts, free), Async.{await, spawn, suspend} (Phase 3, reuses existing $poll machinery), Panic.panic (aborts, free), and deferred Ndet.
Key implementation notes:
- Single-shot is a direct call; no CPS for function-kind ops.
- Polymorphic effect functions (
Vec.map,Vec.fold) thread evidence as a regular parameter — one compiled version, not per-row monomorphization. - Multi-shot handlers exist as a slow path but are not optimized. Ndet is deferred indefinitely.
- Phase 3 (async-as-effect migration) is reframing, not rewriting — the existing
$pollstate-machine lowering IS the operation-kind CPS transform. - Scoped-resumption enforcement via runtime
guardcheck: a captured continuation cannot be invoked under a different evidence context.
Performance floor (from Xie-Leijen 2020 §6 benchmarks, translating their Haskell perf to our LLVM target):
- Function-kind op: within a few percent of direct call.
- Deep effect stacks: constant-time.
- Abort-like effects (Throws, Panic): within 1% of pure.
Details re-derived for LLVM IR in Phase 1. Paper notes in EFFECT_SYSTEMS_NOTES.md; full compilation strategy in EFFECTS_LLVM_COMPILATION_MEMO.md.
11. Migration Guide (for existing Quartz code)
TBD in Phase 1 exit. Covers how to:
- Migrate
$try(x)macro uses totry xkeyword (mechanical rewrite). - Rewrite functions that return
Result<T, E>intoT can Throws<E>(idiom shift — keep Result at FFI boundaries, use Throws internally). - Install a custom
Loghandler for an app (replacesputsusage). - Install test-time
Clock/Randomhandlers. - Convert a cooperative
Reader<Config>pattern to an effect-based one.
12. References
- Leijen 2017 — “Type Directed Compilation of Row-Typed Algebraic Effects.” POPL 2017. Type-system foundation (rows, scoped labels, OPEN/CLOSE rules, inference). PDF
- Xie & Leijen 2020 — “Effect Handlers in Haskell, Evidently.” Haskell 2020. The evidence-passing paper — our compilation-model reference.
- Leijen 2014 — “Koka: Programming with Row Polymorphic Effect Types.” MSFP 2014. Earlier Koka type system, monadic semantics.
- Plotkin & Pretnar 2009 — Algebraic effect handlers, original paper. Foundational.
- Brachthäuser et al. — Effekt. Capability-passing contrast.
- OCaml 5 Effects RFC — Fiber-based compilation contrast.