Skip to content

Explicit Laziness in Nic: Strict by Default, Lazy When You Ask

Most functional languages fall into two camps:

  • Strict (ML, Rust, Go): evaluate everything immediately
  • Lazy (Haskell): evaluate nothing until needed

Nic takes a different approach:

Strict by default, lazy only when you ask for it.

This avoids the unpredictability of full laziness while giving you the power of lazy, memoized computations when you explicitly request them.

Nic's lazy model is simple:

  • Laziness lives inside explicit 'lazy { ... }' blocks
  • The block must explicitly 'return value;'
  • The result is a 'Lazy[T]'
  • Evaluating it is explicit via '.eval()' or 'force(lazy)'
  • It memoizes automatically
  • Zero implicit forcing
  • Zero hidden thunks
  • Zero runtime cost when unused

Let's break it down.


Strict by default

In Nic:

nic
fn main() -> unit {
  let x = expensive(); // immediately runs expensive()
}

This is predictable and easy to reason about — important qualities for a systems language.

But sometimes you need the opposite: defer a computation until (and unless) it's needed.


The lazy { } block

A lazy value is created using a 'lazy { ... }' block:

nic
fn expensive() -> i32 {
  println('Computing...');
  return 42;
}

fn main() -> unit {
  let lazy_val = lazy {
    return expensive();
  };

  println('Before forcing');
  let result = lazy_val.eval();
  println('After forcing');
}

Output:

Before
Computing...
After forcing

The 'expensive()' function runs only when 'eval()' is called.


Lazy values evaluate once (built-in memoization)

Once evaluated, the lazy value is cached:

nic
let x = lazy {
  println('Running...');
  return 100;
};

let a = x.eval(); // prints 'Running...'
let b = x.eval(); // memoized
let c = x.eval(); // memoized

Only the first 'eval()' executes the computation.

This makes lazy values ideal for:

  • cached configuration loading
  • deferred AST transformations
  • expensive computations that might not be needed
  • infinite streams

Lazy[T] is not T (no implicit forcing)

This mistake in many languages creates confusion.

In Nic:

  • 'Lazy[T]' is its own type
  • Nic never forces it automatically
  • Passing a Lazy[T] where T is expected is a compile-time error
nic
fn use_int(n: i32) -> unit { println('got int'); }

fn main() -> unit {
  let v = lazy { return 42; };

  use_int(v);       // ERROR
  use_int(v.eval()); // OK
}

No surprises.
No hidden evaluation.
No debugging nightmares.


Laziness inside structs (defer expensive fields)

nic
struct Config {
  name: string,
  data: Lazy[string],
}

fn load_data() -> string {
  println('Loading from disk...');
  return 'dataset';
}

fn main() -> unit {
  let cfg = Config{
    name: 'app',
    data: lazy { return load_data(); }
  };

  println(cfg.name);       // no load yet
  println(cfg.data.eval()); // loads here
}

This is incredibly powerful for:

  • parsing large files
  • lazy-loading modules
  • deferred computation in compilers (AST → IR)
  • loading assets in games

Conditional laziness (only compute if needed)

nic
fn expensive_default() -> i32 {
  println('computing default');
  return 999;
}

fn get_value(use_default: bool) -> i32 {
  let default = lazy { return expensive_default(); };

  if use_default {
    return default.eval();
  } else {
    return 42;
  }
}

If 'use_default == false', the expensive computation never runs.


Infinite streams powered by lazy tails

Explicit laziness makes infinite structures easy and cheap:

nic
enum Stream[T] {
  Nil,
  Cons(T, Lazy[Stream[T]]),
}

fn integers_from(n: i32) -> Stream[i32] {
  return Cons(n, lazy { return integers_from(n + 1); });
}

Access only what you need:

nic
fn take(n: i32, s: Stream[i32]) -> *List[i32] {
  if n <= 0 { return new Nil; }

  return match s {
    Nil -> new Nil,
    Cons(x, rest) -> new Cons(x, take(n - 1, rest.eval()))
  };
}

Why explicit laziness instead of implicit Haskell-style laziness?

FeatureHaskellNic
Lazinessimplicit, everywhereexplicit, opt-in
Forcingimplicitexplicit .eval()
Performanceunpredictable thunkspredictable strictness
Debugginghardertrivial
Memoizationsometimesalways
Type safetylazy has no typeLazy[T] is explicit

Nic gives you:

  • Haskell’s power
  • Rust’s predictability
  • Zero surprise costs
  • A clean type system

Zero-cost optimization

Nic's compiler aggressively removes overhead:

  1. Lazy value never forced?
    → compiled away entirely.

  2. Lazy value forced exactly once?
    → compiled to a single direct value.

  3. Lazy used multiple times?
    → compiled to a memoized structure (efficient).

This keeps laziness safe to use even in performance-sensitive areas.


When to use laziness

Great for:

  • expensive optional computations
  • deferred loading
  • infinite streams
  • avoiding work inside branches
  • caching results of pure functions

Avoid when:

  • value is cheap
  • value is always needed
  • complex mutation patterns (prefer timelines)

Summary

FeatureSyntax
Create lazy'lazy { return x; }'
Force (method)'lazy_val.eval()'
Force (function)'force(lazy_val)'
Type'Lazy[T]'
Memoizationautomatic

Explicit laziness gives Nic a powerful middle ground:
strict by default, lazy when you need it, predictable always.


Next up:
How Lazy Evaluation Interacts With Temporal Zippers and Infinite Timelines.

Released under the MIT License.