Temporal Zippers
Temporal Zippers are Nic's revolutionary approach to mutation. Instead of modifying variables in place, all state changes are modeled as navigation through time. This gives you pure functional semantics with zero-cost abstractions and built-in time-travel debugging.
The Core Idea
In most languages, mutation is direct:
let x = 0;
x = x + 1; // ERROR in Nic: Cannot mutateIn Nic, you navigate through a timeline instead:
fn main() -> i32 {
timeline x = 0 {
evolve x + 1; // Navigate to new timeline point
evolve x * 2; // Navigate again
return x; // Returns 2
}
}Each evolve creates a new point in time. The variable x isn't mutated—you're moving forward through its timeline.
Timeline Basics
Creating Timelines
A timeline block declares one or more temporal variables:
fn counter(n: i32) -> i32 {
timeline count = 0 {
while count < n {
evolve count + 1;
}
return count;
}
}Multiple variables can share a timeline:
fn fibonacci(n: i32) -> i32 {
timeline (a = 0, b = 1, i = 0) {
while i < n {
evolve (b, a + b, i + 1);
}
return a;
}
}Accessing History
Use @ to access values at previous points in time:
fn demonstrate_history() -> i32 {
timeline x = 0 {
evolve x + 1; // x = 1, past = [0]
evolve x * 2; // x = 2, past = [0, 1]
let initial = x@0; // Value at time 0: returns 0
let middle = x@1; // Value at time 1: returns 1
let current = x; // Current value: 2
return initial + middle + current; // 0 + 1 + 2 = 3
}
}Rewinding Time
Navigate backward with rewind:
fn rewind_example() -> i32 {
timeline x = 0 {
evolve x + 1; // x = 1
evolve x * 2; // x = 2
rewind(); // Back to x = 1
evolve x + 10; // x = 11 (branching from time 1)
return x; // Returns 11
}
}Time-Travel Debugging
One of the most powerful features is built-in debugging that lets you inspect the entire history:
fn buggy_calculation(n: i32) -> i32 {
timeline (result = 0, i = 0) {
while i < n {
if i == 5 {
// Bug: accidentally multiply by 0
evolve (result * 0, i + 1);
} else {
evolve (result + i, i + 1);
}
}
// In debug mode, inspect the entire history
debug {
for t in 0..timeline_length() {
println("At time {}: result = {}", t, result@t);
}
// Shows exactly when result became 0
}
return result;
}
}The debug block only runs in debug builds—it's compiled away in release mode.
Forking and Merging
Timelines can branch into parallel explorations:
fn find_optimal_path(graph: Graph, start: Node, goal: Node) -> Path {
timeline (current = start, path = [start], cost = 0) {
if current == goal {
return path;
}
// Fork timeline for each possible next move
let branches = graph.neighbors(current).map(fn(next) -> {
let branch = fork();
in branch {
let edge_cost = graph.cost(current, next);
evolve (next, path ++ [next], cost + edge_cost);
continue find_optimal_path(graph, next, goal);
}
});
// Merge branches, selecting path with minimum cost
merge branches with fn(timelines) -> {
timelines.min_by(fn(t) -> t.cost)
};
}
}Fork: O(1) Branching
Because timelines use reference-counted sharing (ARC), forking is extremely cheap:
let branch = fork(); // Just increments refcount - O(1)The entire history is shared until one branch modifies it, at which point copy-on-write kicks in.
Merge: Combining Branches
Merge multiple timeline branches with a selection strategy:
merge [branch1, branch2, branch3] with max; // Select branch with highest value
merge branches with fn(ts) -> ts.first(); // Custom selection logicTransactions with Checkpoint/Rollback
Timelines naturally support transactional semantics:
fn transfer_money(from: Account, to: Account, amount: f64) -> Result[unit, string] {
timeline (from_bal = from.balance, to_bal = to.balance) {
// Save checkpoint before transaction
checkpoint();
// Attempt withdrawal
evolve (from_bal - amount, to_bal);
if from_bal < 0 {
// Insufficient funds - rollback
rollback();
return Err("Insufficient funds");
}
// Complete deposit
evolve (from_bal, to_bal + amount);
// Verify consistency
let total_before = from.balance + to.balance;
let total_after = from_bal + to_bal;
if total_before != total_after {
rollback();
return Err("Transaction inconsistency");
}
commit();
return Ok(());
}
}Comonadic Operations
Timelines form a comonad, enabling powerful context-aware computations:
fn running_statistics(data: []f64) -> []Statistics {
timeline value = data[0] {
// Add each data point to timeline
for x in data[1..] {
evolve x;
}
// Use comonadic extend - each position sees full history
return extend(fn(t: Timeline[f64]) -> Statistics {
let history = t.past ++ [t.present];
let mean = sum(history) / len(history);
let variance = sum(history.map(fn(x) -> (x - mean) * (x - mean))) / len(history);
return Statistics {
mean: mean,
variance: variance,
std_dev: sqrt(variance),
min: min(history),
max: max(history),
count: len(history)
};
});
}
}The extend function transforms each point in the timeline while giving it access to the full context (past, present, and future).
Verification with Temporal Logic
Express invariants and temporal properties directly:
fn verified_counter(max: i32) -> i32 {
timeline counter = 0 {
invariant: counter >= 0; // Always non-negative
invariant: counter <= max; // Never exceeds max
eventually: counter == max; // Eventually reaches max
while counter < max {
evolve counter + 1;
}
assert counter == max;
return counter;
}
}These temporal properties are checked at compile time when possible and at runtime in debug mode.
Zero-Cost Abstractions
The magic of temporal zippers is that they compile away in most cases:
Linear Timelines: No Runtime Cost
// This:
timeline x = 0 {
evolve x + 1;
evolve x * 2;
return x;
}
// Compiles to exactly the same LLVM IR as:
let x_0 = 0;
let x_1 = x_0 + 1;
let x_2 = x_1 * 2;
return x_2;The compiler performs timeline analysis and eliminates all overhead for linear (non-branching) timelines.
Performance Guarantees
| Operation | Linear Timeline | Branching Timeline | Debug Mode |
|---|---|---|---|
evolve | 0 cycles (eliminated) | O(1) amortized | O(1) |
| Access current | 0 cycles | O(1) | O(1) |
Access @i | 0 cycles (SSA var) | O(1) array access | O(1) |
fork | N/A | O(1) Arc increment | O(n) copy |
merge | N/A | O(k) for k branches | O(k) |
When to Use Timelines
Good uses:
- State that changes over time in loops
- Algorithms that need to explore multiple branches
- Transactional operations with rollback
- Debugging complex state evolution
- Formal verification of state properties
Just use regular variables for:
- Simple accumulation without history access
- One-shot calculations
- Performance-critical inner loops (though linear timelines have zero overhead)
Comparison
| Approach | Pros | Cons |
|---|---|---|
| Mutable variables | Familiar, direct | Hard to reason about |
| Pure functional | Clean semantics | Requires GC |
| Ownership (Rust) | Memory safe | Can't share history |
| Temporal Zippers | Pure + efficient + debuggable | Novel concept |
The Timeline Type
Under the hood, Timeline[T] is a structured type:
type Timeline[T] = {
past: Arc[Array[T]], // Shared immutable history
present: T, // Current value
future: Arc[Array[T]] // For branches/rewind
}You rarely interact with this directly—the timeline block syntax handles it for you.
Summary
| Feature | Syntax |
|---|---|
| Create timeline | timeline x = initial { ... } |
| Multiple variables | timeline (a = 0, b = 1) { ... } |
| Navigate forward | evolve new_value; |
| Navigate backward | rewind(); |
| Access history | x@3 (value at time 3) |
| Fork | let branch = fork(); |
| Merge | merge branches with strategy; |
| Checkpoint | checkpoint(); |
| Rollback | rollback(); |
| Temporal invariant | invariant: condition; |
| Eventually property | eventually: condition; |
What's Next?
Temporal Zippers are one of Nic's most innovative features. They represent a fundamental rethinking of how we model state in programming—combining the best of functional and imperative paradigms with zero-cost abstractions.
For more details, see the Temporal Zippers Proposal in the compiler repository.