This tutorial helps you go from zero to writing useful FScript scripts.
You will learn:
- how to run a script,
- how to model data with records/lists/maps/options/unions,
- how to use pattern matching,
- how to split scripts with
import, - how to expose host-callable functions with
export let.
brew install magnusopera/tap/fscriptThen run scripts with:
fscript your-script.fssPass script arguments after --:
fscript your-script.fss -- arg1 arg2You can also run from stdin, show version, or start REPL:
cat your-script.fss | fscript -r .
fscript version
fscriptTry REPL right after install:
fscriptgit clone https://github.com/MagnusOpera/FScript.git
cd FScript
make buildThen run scripts with:
dotnet run --project src/FScript -- your-script.fssAnd the same extra CLI modes:
cat your-script.fss | dotnet run --project src/FScript -- -r .
cat your-script.fss | dotnet run --project src/FScript -- -r . -- arg1 arg2
dotnet run --project src/FScript -- version
dotnet run --project src/FScript --Quick REPL check after local build:
dotnet run --project src/FScript --For REPL-specific behavior (multiline submission, retained state, exit controls), see docs/guides/repl.md.
Create hello.fss:
let name = "FScript"
Console.writeLine $"Hello {name}"Run it:
fscript hello.fssCLI-injected environment metadata is available in scripts:
match Env.ScriptName with
| Some name -> Console.writeLine name
| None -> Console.writeLine "stdin"
for arg in Env.Arguments do
Console.writeLine argFScript is expression-first and immutable. FScript is also indentation-based: layout defines blocks.
let a = 40
let b = 2
let result = a + b
Console.writeLine $"result = {result}"Short block example:
let describe x =
if x > 0 then
"positive"
else
"zero-or-negative"Zero-argument functions use () in both the declaration and the call:
let greet () = "hello"
Console.writeLine (greet ())Pipelines are supported:
let text =
"fscript"
|> fun x -> $"{x}-lang"
Console.writeLine textFScript supports the following scalar/basic types:
stringintfloatunit(())bool
let projectName = "fscript" // string
let retries = 3 // int
let ratio = 0.75 // float
let enabled = true // bool
let nothing = () // unitFScript provides expression-based control flow:
if ... then ... elif ... else ...for ... in ... do ...match ... with ...
let classify n =
if n < 0 then
"negative"
elif n = 0 then
"zero"
else
"positive"for value in [1; 2; 3] do
Console.writeLine $"{value}"let describe value =
match value with
| Some x -> $"some:{x}"
| None -> "none"let numbers = [1; 2; 3]
let doubled = numbers |> List.map (fun n -> n * 2)
Console.writeLine $"{doubled}"let maybeName = Some "Ada"
let label = maybeName |> Option.defaultValue "unknown"
Console.writeLine labellet pair = ("pkg", 3)type Project = { Name: string; Version: int }
let p = { Name = "core"; Version = 1 }
Console.writeLine p.Namelet scores = { ["math"] = 18; ["science"] = 20 }
let maybeMath = scores |> Map.tryGet "math"
Console.writeLine $"{maybeMath}"Map keys can be string, int, bool, or a union whose cases all have no payload:
type Priority = | Low | High
let status = { [200] = "ok"; [404] = "not-found" }
let flags = { [true] = "enabled"; [false] = "disabled" }
let messages = { [Low] = "later"; [High] = "now" }
let getOk m = m |> Map.tryGet 200
let ok = status |> getOk |> Option.defaultValue "unknown"No annotation is needed for getOk: its key argument fixes the map key type to int. Each map uses one key type and one value type. Optional annotations use value map[key], such as string map[int] or string map[Priority]. The legacy int map means int map[string], and is the preferred spelling for string keys.
Lists also support optional zero-based indexing:
let values = [10; 20; 30]
let first = values[0]
Console.writeLine $"{first}"type Result =
| Ok of string
| Error of stringUsage:
let success = Ok "done"
let failure = Error "failed"Pattern matching is central in FScript.
let readName value =
match value with
| Some name -> name
| None -> "missing"let describe items =
match items with
| head :: tail -> $"head={head}, tail-size={List.length tail}"
| _ -> "empty"let describePair pair =
match pair with
| ("ok", code) -> $"success:{code}"
| (kind, code) -> $"{kind}:{code}"let cityLabel address =
match address with
| { City = c } -> clet removeKey key values =
match values with
| { [current] = _; ..tail } when current = key -> tail
| _ -> valueslet statusMessage result =
match result with
| Ok value -> $"ok: {value}"
| Error message -> $"error: {message}"Use when to add a boolean condition to a case.
let classify n =
match n with
| x when x < 0 -> "negative"
| x when x = 0 -> "zero"
| _ -> "positive"Record and map patterns are partial in FScript. You can match only the fields/keys you care about; extra entries are allowed.
type User = { Name: string; Role: string; Team: string }
let label user =
match user with
| { Name = n; Role = "admin" } -> $"admin:{n}"
| { Name = n } -> $"user:{n}"let metadata =
{ ["name"] = "core"
["owner"] = "platform"
["tier"] = "gold" }
let summary values =
match values with
| { ["name"] = name; ["owner"] = owner } -> $"{name} by {owner}"
| _ -> "unknown"You can also capture the remaining map entries:
let removeName values =
match values with
| { ["name"] = _; ..rest } -> rest
| _ -> valuesCurried functions are the default.
let add x y = x + y
let inc = add 1
Console.writeLine $"{inc 41}"Recursive functions use let rec:
let rec fib n =
if n < 2 then n
else fib (n - 1) + fib (n - 2)FScript ships with a preloaded stdlib focused on functional collection workflows.
Common families:
Task.*List.*Option.*Map.*
Examples:
let values = [1; 2; 3]
let doubled = values |> List.map (fun n -> n * 2)
let chosen = Some 42 |> Option.defaultValue 0
let m = { ["a"] = 1; ["b"] = 2 }
let hasA = m |> Map.containsKey "a"Concurrent task example:
let pending = Task.spawn (fun () -> 40 + 2)
let answer = Task.await pending
Console.writeLine $"{answer}"Tasks use the native type form 'a task. Spawned thunks run concurrently, Task.await synchronizes on the result, and task failures remain fatal runtime errors.
The core library (List, Option, Map, String, scalar helpers, and ignore) is always available. Console, Task, Fs, Json, Regex, Xml, Hash, and Guid require runtime registration and are enabled by default in the CLI. JavaScript hosts supply their own runtime capabilities.
Full reference:
You can split scripts using import.
main.fss:
import "shared/math.fss" as Math
Console.writeLine $"{Math.sum 20 22}"shared/math.fss:
let sum a b = a + bNotes:
- imported files are
.fss, - import cycles are fatal,
- imported symbols must be accessed through the explicit alias (
Math.sum,Common.join, ...).
FScript is designed to be embedded. At host boundary, scripts can expose explicit entry points while the host controls capabilities and security.
Mark top-level bindings with export let when a host must discover them:
type Command = { Command: string; Arguments: string; ErrorLevel: int }
export let dispatch (command: string) =
[{ Command = "echo"; Arguments = command; ErrorLevel = 0 }]
let exportedName = nameof dispatch // "dispatch"Security/hosting teaser:
- core FScript evaluation is pure in-memory computation,
- side effects exist only through host-exposed externs,
- sandboxing is enforced by host decisions (filesystem root, allowed functions, execution controls).
When embedding, keep this mindset:
- expose only needed externs,
- keep filesystem/network boundaries explicit,
- enforce execution and resource limits in the host, with process isolation when hard termination is required.
- see
docs/specs/embedding-fscript-language.mdfor the embedding API, - see
docs/specs/external-functions.mdfor extern design/registration, - see
docs/specs/sandbox-and-security.mdfor the full security model.
- Sample scripts:
- Specifications index:
docs/specs/README.md - Architecture index:
docs/architecture/README.md samples/