Skip to content

Temporal Zippers: Time-Traveling State for Systems Programming

One of the most unusual features in Nic is Temporal Zippers.

Instead of thinking about mutation as 'changing a variable in place', Nic models state as a timeline that you navigate through. You don't update a value – you move forward (or backward) in time.

This gives you:

  • Pure, replayable state evolution
  • Built-in time-travel debugging
  • Cheap branching and backtracking
  • A path to formal verification with temporal logic
  • All while compiling down to zero-overhead SSA in the simple cases.

In this post, we'll walk through the core ideas behind Temporal Zippers, why they exist, and how they compile away.


From mutation to timelines

In most languages, you write:

nic
let x = 0;
x = x + 1;
x = x * 2;

In Nic, this kind of direct mutation is not allowed. Instead, you describe how 'x' changes over time inside a 'timeline' block:

nic
fn main() -> i32 {
  timeline x = 0 {
    evolve x + 1; // time 1
    evolve x * 2; // time 2
    return x;     // 2
  }
}

Conceptually:

time 0: x = 0
time 1: x = 1
time 2: x = 2 (current)

Accessing the past: '@'

Because state is modeled as a timeline, reading the past becomes first-class:

nic
fn demonstrate_history() -> i32 {
  timeline x = 0 {
    evolve x + 1;
    evolve x * 2;

    let initial = x@0;
    let middle  = x@1;
    let current = x;

    return initial + middle + current;
  }
}

Rewinding and branching

nic
fn rewind_example() -> i32 {
  timeline x = 0 {
    evolve x + 1;
    evolve x * 2;

    rewind();
    evolve x + 10;

    return x;
  }
}

Under the hood, Nic uses ARC + copy-on-write for efficient branching.


Time-travel debugging built in

nic
fn buggy_calculation(n: i32) -> i32 {
  timeline (result = 0, i = 0) {
    while i < n {
      if i == 5 {
        evolve (result * 0, i + 1);
      } else {
        evolve (result + i, i + 1);
      }
    }

    debug {
      for t in 0..timeline_length() {
        println('At time {}: result = {}', t, result@t);
      }
    }

    return result;
  }
}

Transactions: checkpoint & rollback

nic
fn transfer_money(from: Account, to: Account, amount: f64)
  -> Result[unit, string]
{
  timeline (from_bal = from.balance, to_bal = to.balance) {
    checkpoint();

    evolve (from_bal - amount, to_bal);

    if from_bal < 0 {
      rollback();
      return Err('Insufficient funds');
    }

    evolve (from_bal, to_bal + amount);

    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(());
  }
}

Comonads and context-aware computation

nic
fn running_statistics(data: []f64) -> []Statistics {
  timeline value = data[0] {
    for x in data[1..] { evolve x; }

    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)
      };
    });
  }
}

Zero-cost abstractions

Linear timelines like:

nic
timeline x = 0 {
  evolve x + 1;
  evolve x * 2;
  return x;
}

Compile down to:

nic
let x_0 = 0;
let x_1 = x_0 + 1;
let x_2 = x_1 * 2;
return x_2;

Why Temporal Zippers?

They rethink what 'state' means in systems programming:

  • state becomes time-structured
  • debugging becomes replayable
  • branching becomes cheap
  • verification becomes possible

Comparison:

ApproachProsCons
Mutable variablessimple, directhard to reason
Pure FPcleanneeds GC
Rust ownershipsafeno shared history
Temporal Zipperspure + efficient + debuggablenew mental model

Want to go deeper?

See:

Next up:

Explicit Laziness: Strict by Default, Lazy When You Ask.

Released under the MIT License.