Skip to content

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:

nic
let x = 0;
x = x + 1;  // ERROR in Nic: Cannot mutate

In Nic, you navigate through a timeline instead:

nic
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:

nic
fn counter(n: i32) -> i32 {
    timeline count = 0 {
        while count < n {
            evolve count + 1;
        }
        return count;
    }
}

Multiple variables can share a timeline:

nic
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:

nic
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:

nic
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:

nic
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:

nic
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:

nic
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:

nic
merge [branch1, branch2, branch3] with max;  // Select branch with highest value
merge branches with fn(ts) -> ts.first();    // Custom selection logic

Transactions with Checkpoint/Rollback

Timelines naturally support transactional semantics:

nic
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:

nic
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:

nic
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

nic
// 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

OperationLinear TimelineBranching TimelineDebug Mode
evolve0 cycles (eliminated)O(1) amortizedO(1)
Access current0 cyclesO(1)O(1)
Access @i0 cycles (SSA var)O(1) array accessO(1)
forkN/AO(1) Arc incrementO(n) copy
mergeN/AO(k) for k branchesO(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

ApproachProsCons
Mutable variablesFamiliar, directHard to reason about
Pure functionalClean semanticsRequires GC
Ownership (Rust)Memory safeCan't share history
Temporal ZippersPure + efficient + debuggableNovel concept

The Timeline Type

Under the hood, Timeline[T] is a structured type:

nic
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

FeatureSyntax
Create timelinetimeline x = initial { ... }
Multiple variablestimeline (a = 0, b = 1) { ... }
Navigate forwardevolve new_value;
Navigate backwardrewind();
Access historyx@3 (value at time 3)
Forklet branch = fork();
Mergemerge branches with strategy;
Checkpointcheckpoint();
Rollbackrollback();
Temporal invariantinvariant: condition;
Eventually propertyeventually: 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.

Released under the MIT License.