Defer and Laziness
FScript effects are eager by default.
That means an effectful call normally starts when execution reaches it. defer is the language feature that changes that rule intentionally.
configText = defer FileSystem.readFile('./config.json')Creating configText does not start the file read yet. It captures the work so the runtime can start it later when the deferred value is forced or otherwise consumed through the deferred-task surface.
The core idea
Without defer:
text = FileSystem.readFile(path)the read starts immediately when execution reaches the call.
With defer:
text = defer FileSystem.readFile(path)the read does not start yet.
That difference sounds small, but it has real consequences:
- it changes when IO begins
- it changes whether unused work happens at all
- it makes laziness visible in the source
- it gives the runtime a way to represent delayed effectful work explicitly
Why laziness is explicit in FScript
FScript deliberately does not make effectful calls lazy by default.
Instead, the language chooses this split:
- ordinary effectful calls mean “start this work”
defermeans “capture this work, but do not start it yet”
That keeps plain call syntax intuitive. If you see FileSystem.readFile(path), you can read it as an action that begins when reached. If you see defer FileSystem.readFile(path), you can immediately tell that the code is opting into delayed start.
This fits the wider design of the language:
- source code should still read sequentially
- pure code should stay direct and low-overhead
- effectful work should be explicit without needing
async/await - laziness should be visible where it exists, not hidden in the default call model
What defer is for
defer is useful when work is optional, expensive, or dependent on a later decision.
Common good uses:
- fallback work that may never be needed
- secondary IO that depends on a branch
- building a plan before deciding which effects to trigger
- exposing delayed work as part of an API
Example:
import FileSystem from 'std:filesystem'
loadConfig = (mainPath: String, fallbackPath: String, useFallback: Boolean): String => { fallback = defer FileSystem.readFile(fallbackPath) main = FileSystem.readFile(mainPath)
if (useFallback) { fallback } else { main }}The intent is:
- the main config should start immediately
- the fallback config should not start unless the branch actually uses it
What defer is not for
defer is not the normal way to express effectful work.
Do not wrap all effects in defer just because they are effectful. If work should begin when reached, plain calls are the intended style.
defer is also not a replacement for:
- generators
- streams
- ordinary function composition
Those solve different problems.
Deferred work versus generator laziness
Generators are lazy too, but they are a different kind of laziness.
Generator laziness:
numbers = *(): Sequence<Number> => { yield 1 yield 2 yield 3}Deferred-effect laziness:
text = defer FileSystem.readFile(path)In Draft 0.1, generators are for pure lazy sequences. Deferred effects are for delayed host work. The type/effect rules intentionally keep those roles separate, which is why effectful generator work is rejected.
Runtime model
The runtime treats defer as a real runtime-visible construct.
The intended Draft 0.1 behavior is:
defer exprcaptures the expression and its environment safely- creating the deferred value does not start the effect
- forcing or invoking the deferred value starts the work
- repeated forcing should observe the same eventual result
The specs prefer memoized single-start semantics.
That means a deferred file read should not accidentally turn into repeated independent reads each time some part of the program touches it. Once started, later force sites should share the same eventual result.
Why memoization matters
Memoization is what makes defer useful as a semantic tool instead of just being “a function to call later”.
If deferred work restarted every time, it would be easy to trigger duplicated effects:
- multiple file reads
- multiple HTTP calls
- multiple writes
The preferred model is:
- capture once
- start once
- reuse the same eventual outcome
That is both easier to reason about and more aligned with the design goal of predictable effect behavior.
Forcing
The docs talk about forcing because that is the semantic event that matters:
- before forcing, the effect has not started
- forcing triggers the deferred work
- once completed, later uses observe the same memoized result
In the current implementation, the runtime-backed task surface includes helpers such as Task.defer and Task.force. Those are useful when reading implementation-focused examples, but the language concept is bigger than one module API: defer is part of the language execution model.
Example: optional expensive work
import FileSystem from 'std:filesystem'
loadReport = (summaryPath: String, rawPath: String, includeRaw: Boolean) => { summary = FileSystem.readFile(summaryPath) raw = defer FileSystem.readFile(rawPath)
if (includeRaw) { { summary, raw, } } else { { summary, raw: Null, } }}The summary starts eagerly because the program definitely needs it. The raw content is delayed because it depends on a branch.
Example: planning work before committing to it
import FileSystem from 'std:filesystem'
buildPlan = (configPath: String, secretsPath: String) => { config = defer FileSystem.readFile(configPath) secrets = defer FileSystem.readFile(secretsPath)
{ config, secrets, }}This is a good pattern when you want to assemble a plan and then decide later which work should actually begin.
Compared with JavaScript and TypeScript
JavaScript often mixes eager and lazy behavior depending on the API:
- some promise-returning functions start immediately
- callbacks are often lazy
- frameworks add their own task models
- “when does this actually start?” can depend on conventions rather than language rules
FScript narrows that model:
- ordinary effectful calls start eagerly
deferis the explicit lazy form- consuming a result may suspend
- laziness is a visible source-level choice
That makes delayed work easier to teach and easier to spot when reading code.
Good habits
- use plain effectful calls by default
- use
deferonly when delayed start is meaningful - keep deferred work near the branch or API that justifies it
- prefer small effectful shells with mostly pure transformation logic around them
Common mistakes
- treating
deferas the default style for all effects - assuming laziness is automatic
- using deferred work when the result is definitely needed immediately
- confusing deferred effects with generator-based lazy sequences
Current implementation notes
The current repository already has a meaningful defer slice implemented:
- effect analysis classifies current callables as
Pure,Effectful, orDeferred - the shared interpreter supports lazy memoized
defer - the shared runtime tracks deferred/task state transitions such as
created,ready,running,waiting,completed, andfailed std:taskcurrently includesTask.deferandTask.force- ordinary implemented host operations already start eagerly when reached unless explicitly deferred
The main remaining runtime gap is not the basic defer semantics. It is broader scheduler parity and longer-lived dependency draining across whole evaluations.