Effects
Effects are one of the core ideas in FScript.
If you only learn one runtime concept beyond the syntax, learn this one: FScript distinguishes between pure code and effectful code, and that distinction is part of both the language design and the runtime model.
The short version is:
- pure expressions evaluate immediately
- effectful calls start eagerly when execution reaches them
- source code still looks direct and sequential
- the runtime suspends only when an effectful result is actually needed and not ready yet
deferis the explicit way to opt out of eager start
This is a major difference from JavaScript and TypeScript. FScript does not make you write async and await, but it also does not pretend effects do not exist. Instead, the compiler and runtime track them for you.
What counts as an effect
An operation is effectful when it interacts with the outside world or depends on runtime state that is not just local pure computation.
Typical examples include:
- reading a file
- writing a file
- making an HTTP request
- reading time
- generating randomness
- interacting with the process or terminal
Pure work, by contrast, is just computation over values:
- arithmetic
- string building
- record construction
- array transformation
- pattern matching
- calling other pure functions
Pure code
Pure code:
- has no observable external interaction
- can be reasoned about from its inputs alone
- evaluates immediately
- should not pay scheduler overhead
Example:
fullName = (first: String, last: String): String => { first + ' ' + last}This function is pure. It computes a value and returns it. Nothing suspends, nothing is scheduled, and nothing touches the outside world.
Another example:
import Array from 'std:array'import String from 'std:string'
normalizeNames = (rows: { name: String }[]): String[] => { rows |> Array.map((row) => String.trim(row.name)) |> Array.filter((name) => name !== '')}Even though this does multiple steps, it is still pure because every step is just transformation of existing values.
Effectful code
Effectful code performs host-backed work.
Example:
import FileSystem from 'std:filesystem'
readConfig = (path: String): String => { FileSystem.readFile(path)}FileSystem.readFile is effectful because it crosses the runtime boundary and talks to the filesystem.
A larger example:
import FileSystem from 'std:filesystem'import Json from 'std:json'
loadPort = (path: String): Number => { text = FileSystem.readFile(path) config = Json.parse(text) config.port}This function contains both effectful work and pure work:
FileSystem.readFile(path)is effectfulJson.parse(text)is a boundary operation over outside data- reading
config.portis ordinary pure value access onceconfigexists
Why FScript treats this so seriously
FScript is designed around three related goals:
- pure code should stay cheap and direct
- effectful code should still read naturally
- the runtime, not user-written promise plumbing, should manage suspension
That is why the specs repeatedly describe FScript as “async by semantics” rather than “async by syntax”.
In JavaScript and TypeScript, effectful workflows usually become visible through:
Promiseasyncawait- explicit batching helpers such as
Promise.all
In FScript, those source-level constructs are not the main model. The language keeps the distinction between pure and effectful code, but moves most of the machinery into the compiler and runtime.
Eager effect start
Effectful calls start eagerly when execution reaches them.
That means this function:
import FileSystem from 'std:filesystem'
copyFile = (from: String, to: String): String => { text = FileSystem.readFile(from) FileSystem.writeFile(to, text) text}is not interpreted as “do nothing until text is explicitly awaited”. There is no await. Instead, the runtime behaves more like this:
- execution reaches
FileSystem.readFile(from) - the read starts
- execution continues until it needs the actual value of
text - if the value is not ready, execution suspends there
- once
textis ready, the write can start - the block returns
text
This keeps source code sequential while still allowing the runtime to manage effectful work efficiently.
Implicit suspension
FScript source treats values from effectful calls as if they were ordinary values:
content = FileSystem.readFile(path)size = String.length(content)There is no explicit await content.
Instead, the runtime inserts suspension points automatically. If content is ready by the time String.length(content) needs it, execution continues directly. If not, execution suspends until the read completes.
This is one of the defining language behaviors:
- effectful calls start eagerly
- consumption of their results may suspend
- pure code stays direct
Observable ordering
FScript is not “start everything immediately and hope for the best”.
The runtime is meant to preserve observable source ordering unless effects are proven independent. That matters for code like this:
import FileSystem from 'std:filesystem'
updateLog = (path: String): Undefined => { before = FileSystem.readFile(path) FileSystem.writeFile(path, before + '\nnext line')}The write depends on the read result, so the runtime must preserve that dependency and order.
The specs also leave room for overlap of independent work when dependencies allow it. For example, if two effectful calls do not depend on each other, the runtime may be able to overlap them without you writing something like Promise.all(...).
defer is the escape hatch
Because ordinary effectful calls start eagerly, FScript needs a way to say “not yet”.
That is what defer is for.
lazyConfig = defer FileSystem.readFile('./config.json')Creating the deferred value does not start the read yet. The work starts only when the deferred value is forced or invoked, depending on the surrounding runtime and helper surface.
Use defer when:
- work is optional
- work is expensive and may never be needed
- you want to build a plan before starting it
- you want laziness to be obvious in the source
Do not use defer as your default style. The language is designed around eager effect start as the normal case.
Why FScript chose eager-by-default effects
There is another plausible design for a language like this:
- effectful calls could be lazy by default
- some explicit form like
awaitcould mean “start this now and suspend here if needed”
That design has real appeal. It can make batching and delayed work feel more automatic. It can also make effectful values behave more like suspended plans than immediate actions.
FScript does not choose that design.
Instead, FScript treats ordinary effectful calls as actions that begin when execution reaches them, and uses defer for the smaller set of cases where delayed start is the right semantics.
The main reason is that it keeps the basic meaning of a function call intuitive:
writeFile(path, text)means “start writing the file”readFile(path)means “start reading the file”defer readFile(path)means “capture this work, but do not start it yet”
That split keeps two concepts separate:
- starting work
- waiting for a result
If laziness were the default, an explicit await-like form would often have to mean both “start this work now” and “block here until the result is available”. FScript keeps those concerns apart:
- ordinary effectful calls start eagerly
- consuming the result may suspend
deferchanges the start moment explicitly
What the alternative would feel like
A lazy-by-default design would look more like this:
await FileSystem.writeFile(path, text)person = getPerson()await FileSystem.writeFile(otherPath, otherText)person.firstNameIn that world:
getPerson()would describe work without starting it yet- the explicit
awaitsites would trigger immediate execution - reading
person.firstNamemight also become the moment thatpersonfinally has to run
That model can work, but it changes the meaning of ordinary call syntax. A plain call stops meaning “do the thing now” and starts meaning “build a suspended computation”.
FScript is intentionally built around a different intuition:
- plain effectful calls mean “start this work”
- plain pure calls mean “compute this value”
defermeans “delay this effectful work on purpose”
Why this fits the rest of the language
Eager-by-default effects line up well with the rest of FScript’s design:
- source code is meant to read sequentially
- pure code should stay direct and low-overhead
- effectful work should be explicit without requiring promise syntax
- laziness should be visible where it exists
It also makes documentation and API reading simpler. When a user sees an effectful standard-library function call, they can assume it starts when reached unless the source explicitly says otherwise with defer.
Effect inference
Draft 0.1 does not require you to write effect annotations on functions, but the compiler still tracks effect information.
The broad rule is simple:
- if a function only calls pure code, it stays pure
- if a function calls an effectful function, it becomes effectful
- pure functions cannot secretly remain typed as pure while performing effects
Example:
trimName = (value: String): String => { String.trim(value)}This stays pure.
import FileSystem from 'std:filesystem'
readName = (path: String): String => { text = FileSystem.readFile(path) String.trim(text)}This becomes effectful because it calls FileSystem.readFile.
That effect information is important for:
- diagnostics
- tooling
- generated docs
- keeping pure paths free of unnecessary runtime machinery
Pure helper, effectful shell
One of the best design habits in FScript is to keep effectful boundaries thin and move most logic into pure helpers.
Example:
import FileSystem from 'std:filesystem'import Json from 'std:json'import String from 'std:string'
type User = { name: String, active: Boolean,}
parseUserName = (text: String): String => { value = Json.parse(text) String.trim(value.name)}
readUserName = (path: String): String => { text = FileSystem.readFile(path) parseUserName(text)}This split is useful because:
- the pure helper is easier to test
- the effectful boundary is small and obvious
- most of the program remains ordinary value transformation
Example: file read followed by pure transformation
import FileSystem from 'std:filesystem'import String from 'std:string'
loadTitle = (path: String): String => { text = FileSystem.readFile(path) lines = String.split('\n', text) firstLine = lines[0] String.trim(firstLine)}How to read this:
- file reading is effectful
- splitting and trimming are pure
- the runtime may suspend while waiting for
text - once
textexists, the remaining work is ordinary pure evaluation
Example: two independent effectful calls
import FileSystem from 'std:filesystem'
loadPair = (leftPath: String, rightPath: String): { left: String, right: String } => { left = FileSystem.readFile(leftPath) right = FileSystem.readFile(rightPath)
{ left, right, }}This example matters because it shows the difference between source order and runtime scheduling.
Source order is still clear:
- reach the left read
- reach the right read
- build the result record
But because the reads are independent, the runtime may be able to overlap them. The programmer does not need to write explicit promise orchestration for that possibility.
Example: optional work with defer
import FileSystem from 'std:filesystem'
loadReport = (path: String, includeRaw: Boolean): { summary: String, raw: String | Null } => { rawText = defer FileSystem.readFile(path)
summary = 'report requested'
if (includeRaw) { { summary, raw: rawText, } } else { { summary, raw: Null, } }}The important part here is not the exact final syntax of every force site. The important part is the language-level intent:
- without
defer, reaching the read would start it immediately - with
defer, the read stays delayed unless the code actually needs it
Effects and generators
Draft 0.1 intentionally keeps generators pure-lazy.
That means this shape is valid:
numbers = *(): Sequence<Number> => { yield 1 yield 2 yield 3}But yielding effectful work from a generator is a type/effect error in Draft 0.1.
That restriction exists because FScript does not want generators to become a confusing second async model. Effects, deferred tasks, and generators each have their own job:
- generators: pure lazy sequences
- ordinary effectful calls: eager-start host work
defer: explicit laziness for effectful work
Effects and type boundaries
Effects matter especially at boundaries where outside data enters the program.
Examples:
- reading text from a file
- parsing JSON
- future native interop
At those boundaries, values may need validation before the rest of the program can treat them as trusted typed data.
That is why a lot of real FScript code will naturally follow this pattern:
- perform an effectful operation to get outside data
- validate or decode it
- continue with pure typed logic
Compared with JavaScript and TypeScript
This table is a useful mental shortcut:
- JavaScript/TypeScript: effectful workflows are usually spelled with
Promise,async, andawait - FScript: effectful workflows are tracked semantically by the compiler and runtime
- JavaScript/TypeScript: laziness versus eagerness often depends on API design
- FScript: eager start is the default,
deferis the explicit lazy form - JavaScript/TypeScript: concurrency often requires explicit batching helpers
- FScript: the runtime may overlap independent work while preserving observable ordering rules
Good habits
- keep pure logic separate from host interaction
- keep effectful boundaries small
- use
Resultfor expected failures at boundaries - use
deferonly when you really want delayed work - remember that a function becomes effectful if it calls effectful code
Common mistakes
- assuming effectful work is lazy by default
- treating
deferas the normal form instead of the exception - mixing parsing, validation, file IO, and business logic into one giant effectful function
- trying to use generators as async streams
Current implementation notes
The current repository already has a meaningful effect-analysis and runtime slice:
- the compiler classifies current callables as
Pure,Effectful, orDeferred fscript checkvalidates effect analysis for the supported frontend- the shared runtime and interpreter already support eager ordinary effect start for implemented host operations
- deferred work is memoized in the current runtime
- effectful generator work is rejected in the current implementation
The main remaining gap is not the basic effect model itself. It is the longer-lived scheduler/runtime parity work described in the implementation plan.
Where to go next
- read Defer and Laziness for the lazy side of the model
- read Execution Model for the runtime view
- read Tasks for the scheduler-facing runtime surface
- read Errors and std:result for boundary-friendly failure handling