weir
On this page

Reference

Lexical

Comments, identifiers, keywords, string forms, numbers, unit literals, operators, sigils, directives, and attributes.

Comments

// runs to the end of the line. Full-line and trailing forms both work:

let retries = 3 // trailing
// full-line
print $"{retries}"

A // needs a space before it or must start the line. Inside a word it is just text, so http://a in a command line stays one word.

On a command line, an unquoted // standing alone as an argument starts a comment. Quote it ("//") to pass the two characters to a command.

// inside any kind of string is just text. Inside an interpolation hole it is a parse error; write the comment outside the string:

print $"{1 // 2}" // a comment cannot live inside a hole

/// lines are doc comments. A doc comment attaches to the declaration directly below it. A blank line in between detaches it, but an attribute line ([<...>]) in between does not. The text shows on hover and in completion. On an Args.load field, the first line is also the field’s --help text.

Identifiers

An identifier starts with a letter or _ and continues with letters, digits, _, or '.

Binding names start lowercase. Uppercase names are for types, modules, and constructors:

let Foo = 1 // binding names start lowercase
print $"{Foo}"

Keywords

A keyword can’t be used as a name — let let = 1 is refused with 'let' is a keyword.

kindkeywords
shared with F#do, elif, else, false, for, fun, function, if, in, let, match, module, of, then, true, type, when, with
weir’s ownalways, from, import, plan, poll, pure, readonly, retry, to, try, until, within
reserved, unusedmutable, rec
reserved for a helpful errordef, return, while

After within comes the kind of scope: cd, env, lock, proc, serve, tmp. Data is read with from (json, jsonl, table, toml, xml, yaml) and written with to (json, jsonl, yaml). These words are special only in those positions; anywhere else they are ordinary names.

pure and readonly each start their own indented block. Code in a pure block may have no effects at all; code in a readonly block may read (files, the environment, the clock, HTTP GETs) but change nothing. pure also marks a function: let pure f x = …. Neither goes after within.

The reserved words are deliberate. Two of them, rec and mutable, are reserved with no meaning, so writing them out of F# habit gives an error instead of quietly binding a name:

let rec f x = x // 'rec' is reserved; weir has no let rec
print "y"

Four more are reserved only so that the error can help. weir has no such constructs, but if you type one out of habit from another language, the error tells you what to write instead: while points at retry/poll and seq iteration, return at a function’s last expression, try at | complete and the within scopes, and def at let:

while true do print "x" // 'while' is not a weir word — the error points at retry/poll

Strings

Strings come in five literal forms, each written on one line. For text that spans several lines, use a text block (below).

Ordinary — "..." with escapes \", \\, \n, \t:

print "a\tb"

A line break inside an ordinary string is a parse error:

let s = "a
b" // strings close before end of line
print s

Verbatim — @"...". Backslashes are literal, and "" stands for one quote character:

print @"C:\temp\x"

Triple-quoted — """...""". No escapes at all; a bare " is fine inside. The string closes at the first """.

Interpolated — $"...". A {expr} is a hole; {{ and }} are literal braces:

let n = 2
print $"n={n} with {{literal braces}}"

Raw interpolated — $"""...""". No escapes, but holes work, so one string can contain a backslash, a bare quote and a hole.

There is no $@"...":

print $@"x" // no verbatim-interpolated form; use $"""...""" instead

Text blocks — for text that spans several lines. A line ending in <<< starts one: the indented lines below it are the text, and its value is a seq<string> with one element per line.

  • Everything inside is literal, including $, { and quotes.
  • Indentation is kept relative to the first line. Blank lines inside the block are kept; blank lines at the end are dropped.
  • Indentation is spaces only, inside blocks too: a tab in a line’s leading whitespace is an error. A tab is content only after the line’s first non-whitespace character.
  • $<<< puts values into the text with the same {expr} holes as $"…" strings; {{ and }} are literal braces, and $ stays as is.
  • $$<<< uses $name and ${expr} instead and leaves braces and quotes alone, which suits JSON or config templates. $$ is a literal $, and a $ that does not start a name stays as is, so $1 survives. Values are inserted exactly as they are, so a value containing " or a newline can break the output; build the text with to json when values aren’t trusted.
  • The marker goes at the end of a line (let x = <<<) or alone on the line below. A marker with no indented block under it is an error.
  • To pipe the whole block, put a |> line after it, at or left of the marker’s column (|> Seq.length). A |> indented deeper than the text is just text.
let n = 2
let block = $<<<
    n={n} with {{literal braces}}
    $HOME stays a byte

block |> Seq.iter print

Numbers

Integer literals are plain digits. A minus sign glued to the number or name after it, with a space (or nothing, or another operator) before it, makes that value negative — -5, 2 * -3, and f -1 passes −1 to f:

print $"{-5} {2 * -3}"

Spaced the same on both sides, it subtracts: x-1 and x - 1 both mean x minus 1. (So x -1 is not subtraction — it passes −1 to x.)

Float literals have a decimal point (1.5). Nothing converts implicitly: mixing int and float is a type error that suggests Float.ofInt:

print $"{3 / 2.0}" // no implicit widening; use Float.ofInt 3

A float is always finite. An operation whose result would be NaN or Infinity raises an error:

print $"{1.0 / 0.0}" // float division by zero raises

Integer overflow raises rather than wrapping.

Duration literals

Each literal has a single unit: 500ms, 30s, 2m, 1h.

print $"{90s + 45s}"

Compound literals do not exist — 1m30s is a parse error:

let d = 1m30s // one unit per literal; write 90s, or add 1m + 30s
print $"{d}"

show renders the compound form (90500ms shows as 1m30.5s), and Duration.parse reads that form back.

Size literals

Only binary units have literals: 10MiB, 4KiB, 1GiB.

print $"{1KiB + 512B}"

The SI suffixes are not literals:

let s = 10MB // binary units only in literals; Size.parse reads SI text
print $"{s}"

Size.parse accepts SI suffixes in text it reads, as powers of ten.

Operators

Every infix operator, from lowest precedence to highest. All are left-associative:

bindsoperatorsmeaning
loosest|> >>pipe-apply; function composition (left to right; no <</<|)
||boolean or
&&boolean and
== <> < <= > >=equality and comparison
+ -additive
tightest* / %multiplicative; % is integer remainder

A minus sign directly before a value negates it, even after another operator (2 * -3).

% is integer remainder. How it handles signs (truncated, unlike Python’s floored %) and why floats are refused are covered in Types.

The one precedence rule that tends to surprise: |> and >> share the loosest level, so xs |> f >> g is (xs |> f) >> g. The error message says so:

[1; 2] |> Seq.map show >> Seq.length // '>>' and '|>' share precedence; write xs |> (f >> g)

= is not equality. It binds a value in let and record fields, and using it as a comparison is a check error that suggests ==:

if 1 = 1 then print "eq" // use '==' for equality

| is not in the table because it is the command pipe, not an operator. Which one you use depends on the right-hand side: git log | grep x feeds a program, git log |> Seq.head applies a function, and using the wrong one is an error that names the right one.

Sigils

These are all of weir’s sigils:

sigilwheremeaning
$namecommand argvsplice a binding as exactly one argv word
$@xscommand argvsplat a seq: N elements become N words
$(...)expressioncapture a command chain’s output
$e(...)expressionthe same, under the env overlay bound to e
^cmd / ^$namecommand namealways run an external program: one named literally, or by a $-spliced value
_.fieldexpressionfield-access shorthand (fun x -> x.field)
_[i]expressionindex shorthand (fun x -> x[i])
_patternsthe wildcard

weir has no ! for negation; use the word not:

if !true then print "x" // '!' is not negation — write 'not true'

Directives

A # line is an instruction to the tooling, not part of the language. Some directives are read when a script is checked; others run immediately, either from the REPL’s init file (read once at startup) or typed at the REPL prompt. Each directive belongs to exactly one of these three contexts:

directivepurposecontext
#sigbind a command signature filescript file, read at check time
#schemathe schema a yaml block is checked against (currently written as schema= on the marker line)script file, read at check time
#sessionsession settings: cwd, env, echo cap, log levelinit.weir, read once at startup
#aliasa command alias (#alias k = kubectl)init.weir (also works at the prompt)
#helpthe directives, the modules, one member’s docREPL prompt, runs now
#findfuzzy help search (fzf + live preview; substring fallback)REPL prompt, runs now
#echohow many elements of a lazy seq the REPL echoesREPL prompt, runs now
#inferdraft named types from a JSON/YAML sampleREPL prompt, runs now
#savedistill the session to a runnable scriptREPL prompt, runs now
#historyshow history (#history all, #history N last N); prints the file pathREPL prompt, runs now
#quitleave the REPLREPL prompt, runs now

The REPL manual covers the prompt set and the init file. Signatures and schemas cover the check-time ones.

Attributes

[<...>] attributes are read at check time and have no effect at runtime. The set is fixed.

For command-line flags (Args.load):

  • [<Short "c">] — pin a one-character short flag ("h" is reserved for --help)
  • [<NoShort>] — suppress the derived short
  • [<Default lit>] — a literal default: string, int, float, bool, duration, or size

For data read and written with the adapters (Adapters):

  • [<Wire "key">] — the JSON/YAML key for a field, when that key is not a legal weir identifier
  • how a time, duration or size is encoded: [<Iso8601>], [<EpochMs>] or [<EpochSec>] on an Instant; [<Millis>] or [<Seconds>] on a Duration; [<ByteCount>] on a Size
  • [<Tag "field">] on a union, naming the field that says which case it is, and [<Other>] on the case that takes any unknown value
  • [<Attr>] and [<Elem "Name">] for XML attributes and repeated child elements

And [<Positional>], in signature files only, where a foreign CLI’s operands need describing.

A typo is a check error with a did-you-mean:

type Cli = {
    [<Shrot "c">]
    clean: bool
}
let c = Args.load Cli // unknown attribute 'Shrot'. Did you mean 'Short'?
print $"{c.clean}"

The other literals

true and false are keywords. () is the unit literal, the value an effect returns.

There is no char type; a single character is just a one-character string:

let c = 'a' // no char type; "a" is a one-character string

Duration and Size have literals; Instant deliberately does not. You get a point in time from Instant.now (), Instant.parse or Instant.parseWith.

Statement layout

A statement starts at column 0; the full layout and block rules are Statements.

In command argv

None of the above applies inside a command’s argv. 30s, 10MiB, a glued //, and a {brace} are all ordinary words there; it is up to the program to interpret its arguments.

Statements

The statement rule

There are two kinds of statement:

  • a command line streams its output as the program produces it;
  • every other statement must have type unit — bind its value (let x = …) or print it (expr |> print).

A value that is computed and then ignored is a check error, not silent output:

ls |> Seq.length // computes an int and discards it — bind it, or pipe it to print

Layout

A statement starts at column 0. Indented lines continue it, and the next line at column 0 ends it. Indentation is spaces only — a tab in a line’s leading whitespace is an error, inside text and yaml blocks too. Blank lines and comment lines are ignored for layout, so you can put gaps inside a block.

An indented let ends at the next line with the same indentation. An open bracket keeps the statement going, but the closing bracket must not be at column 0 while the statement is unfinished:

let xs = [
    1
    2
    ]

print $"{xs |> Seq.length}"

One layout form is triggered by how a line ends. A line ending in the yaml marker or a heredoc marker (<<</$<<</$$<<<) opens a block: the indented lines below are that literal’s content, not weir statements, and the first less-indented line ends it. The heredoc forms are covered in Lexical, the yaml template in the guide and Adapters. A marker with no indented block below it is an error that names the marker.

let motd = $<<<
    one line, {1 + 1} holes, $literal bytes

motd |> Seq.iter print

Blocks

Lines at the same indentation inside a block run in order. Each one except the last must be unit, and the last expression is the block’s value. A check before the result, such as if … then fail …, works as you would expect:

type Target = { Name: string }

let target =
    let stack = "web"
    if stack == "" then fail "usage"
    { Name = stack }

print target.Name

Sequencing with ;

; puts several statements on one line. After an if or match, everything following the ; still belongs to the body, as if it were an indented block. Both statements below are in the then-branch, so nothing prints:

if 1 > 2 then print "a" ; print "b"
print "after"

To run something after an if, put it on its own line (or wrap the if in parentheses). In a command line, ; is a literal argument; it does not chain commands, so put one command per line.

match arms and a trailing |>

What a |> on its own line after a match arm does depends on its column. Lined up with the arm’s |, it ends the match and pipes the whole match — no parentheses needed:

match 5 with
| 5 -> 50
| _ -> 0
|> print

That prints 50: the whole match is piped to print, not just the last arm’s 0. Put the |> at or under the start of the arm’s body and it continues that arm instead, whether inline or on its own line:

match 5 with
| n -> n
       |> print

Any column in between — left of the body but right of the | — is an error. The error tells you the body’s column and suggests either moving back to the | (to pipe the whole match) or under the body (to continue the arm). weir does not guess which one you meant.

if / elif / else

if is an expression. else is optional only when the then-branch is unit, and elif is short for else if. The condition can be a command chain written directly: its arguments stop at then, and the condition must still be a bool:

if git rev-parse HEAD | succeeds then print "in a repo"

let

let binds a value, defines a function (let f x y = …, curried), or destructures (let host, port = target, let { names = n } = row). The right-hand side can be a command chain written directly, wherever a let appears:

let tree = git rev-parse HEAD | line
print tree[..6]

for … in … do

for runs a block for each element of a seq, for its effects. The seq goes on the right of in, a pattern on the left. The body streams output and raises errors as each iteration runs. It is the same as Seq.iter; to transform values, use pipelines. The comprehension form [for … -> …] builds a seq:

for greeting in ["hello"; "again"] do
    print greeting

let squares = [for x in [1..5] -> x * x]
squares |> Seq.map show |> print

Declarations

type and module are statements too; import must come first in the file. A file that starts with module is a module: it contains only declarations, can be imported, and cannot be run. Directives (#sig, #schema) go at the top of the file and are read at check time.

Commands

How a line decides

The first word of a statement decides how the line is read. If it is a name bound in scope (or a builtin), the line is an expression — an ordinary function application. If it is an unbound bare word, weir runs the external program of that name, resolved against PATH before anything runs. Builtins take precedence over PATH, and ^ls forces the real program. Inside a function body, its parameters also take precedence over PATH:

let greet name = print $"hi {name}"
greet "io"
echo hi io

Argv

On a command line every word is passed as written: nothing expands, splits or concatenates. A spliced value becomes exactly one argv word. Pieces written next to each other are an error rather than being glued together:

let root = "build"
rm -rf $root/* // argv words do not concatenate — write $"{root}/*"

A command line continues onto indented lines, like every other statement. There is no \ line continuation:

printf "%s %s\n" first
    second

What command lines do not do:

  • no backslash escapes — a \ word is an error that shows the replacement (\; is ";", a literal backslash argument is "\\", and \ls is ^ls)
  • no glob expansion — Path.glob is a function
  • no $VAR expansion — splice weir bindings instead
  • ~ is the one exception, and only when typed directly: an unquoted word that is ~ or starts with ~/ means your home directory, resolved when the line runs (cat ~/.bashrc, ~/bin/tool, cd ~). A quoted "~/x", an interpolation and a spliced value stay literal, because weir never re-reads data as syntax. ~user is not expanded. In an expression, build the path with Path.home (), or with Path.configHome/Path.stateHome/Path.cacheHome for the XDG directories: File.read $"{Path.home ()}/.bashrc". Each is a pure unit -> string. On Windows they use %APPDATA%/%LOCALAPPDATA%; on POSIX, the $XDG_* variables with the usual fallbacks such as ~/.config
  • cd dir changes the session’s directory (relative to the current one; cd alone goes home) and returns the absolute path it moved to. pwd is the current directory as a string, read at the moment it is used
  • no && — write two statements, or chain them with | and / | or
  • no redirects — > is passed as a literal argument, with a warning that suggests File.write

When you need bash behaviour, run bash: sh -c "the line". Inside that quoted string, $w is a shell variable, not a weir binding — to pass a weir value, interpolate it (sh -c $"echo {w}").

Splices

$name splices a binding, (expr) splices an expression, and $@xs splats a seq, so N elements become N words:

let marker = "ref"
echo tagged $marker (40 + 2)
echo files: $@(["a.txt"; "b.txt"])

A splice can also read a record field path — $cli.tag, $cli.out.dir, $@cli.files, or a dynamic head ^$cli.bin. It is still one word (or one program, for a head). Only an identifier extends the path, so $x. and $x/y are refused as glued words. This means bash’s $f.bak reads a field; on a string it is an error that suggests $"{f}.bak".

type Cli = { tag: string; bin: string }
let cli = { tag = "v1"; bin = "echo" }
^$cli.bin release $cli.tag

Typed values do not convert to argv implicitly. Splicing a Duration is an error that suggests the explicit forms (Duration.toMillis d or show d). A Secret does splice in the clear — passing it to a command is what the type is for — but it is refused in interpolation and at the serialization boundaries.

Pipes

The operator depends on what is on the right: | feeds a program, |> applies a function. Using the wrong one is an error that names the right one:

git ls-files | sort | head -1
git ls-files |> Seq.length |> show |> print

A value on the left of | becomes the program’s stdin:

["b"; "a"] | sort

Streaming, capture, and the markers

A command written as a statement on its own streams its output. With a let in front, the output is captured instead, as a seq<string> with one element per line, and nothing is streamed. A bare command is an ordinary statement in any block and any match case, so most command lines need no marker at all:

let files = git ls-files
print $"{files |> Seq.length} tracked"

if 2 > 1 then
    git status --porcelain
    print (($(git rev-parse HEAD) |> Seq.head)[..6])

$(...) captures a command chain where a bare command can’t go: inside an expression such as an interpolation hole, a record or a splice (as in the example above). A command line runs to the end of the line — ; there is just an argv word — so each command needs a line of its own.

Environment variables for a single command go before it, as in bash: EDITOR=nano git commit. Each stage of a pipeline takes its own; after the program name, CC=gcc is an ordinary argument. An env overlay bound to e applies to a block of commands with within env e, to a capture with $e(...), and combines with a prefix — where both set the same name, the prefix wins. There is no ! negation; use the word not. To choose between two known tools, branch on the whole command line. When the program really is a runtime value, run it with a dynamic head.

Dynamic heads

^$name runs the program named by a string value — the same force-external ^, applied to a runtime string. The value is always one program name and is never re-parsed: a value containing spaces is one (odd) program name, the arguments after it splice as usual, and nothing is globbed or word-split. Use it to dispatch to plugins or callbacks without sh -c:

let tool = "printf"
^$tool dyn-head-ok

A string-typed capture works directly as a head (^$(… | line)). A seq-typed value is refused, because one program has to be chosen and weir never picks a line for you:

^$(git branch) status // which line is the program? bind and pick first

The program is looked up when the line runs, not at check time. weir check reports no command-not-found for a dynamic head, since there is nothing to look up yet. A missing program is a run-time error that points at the line and names the value, and weir check --can reports the head as not statically known (--strict treats it like other sites it cannot see through). Reifiers, pipes, captures and env overlays work exactly as with a literal head: ^$tool build | complete gives you the computed program’s exit.

Reifiers and chaining

A failing command raises an error when its output is read. A reifier — a | stage after the command — turns the run into a value instead. Where the command’s output goes follows from what you get back: it is captured when the value contains it, streams to the terminal when you only get the exit status, and is discarded by succeeds:

formoutputresult
cmd | succeedssilentbool — exitCode == 0, exactly
cmd | completecaptured{ exitCode; stdout; stderr }
cmd | orFail "msg"streamsunit; raises msg (exit N) on nonzero
cmd | exitCodestreamsthe code as int; never raises
cmd | linecapturedthe one trimmed stdout line, a string; raises on nonzero or 0-or-2+ lines
cmd | textcapturedthe whole stdout as one string, trailing blank lines dropped; raises on nonzero
cmd | execthe child’snever returns — the command replaces the weir process
let r = sh -c "echo out; exit 3" | complete
print $"exit {r.exitCode}, said {r.stdout |> Seq.head}"

A reifier can also follow a whole chain, a | b | c | line. The chain’s exit code is that of the leftmost failing stage (where the problem began), or 0. A complete record holds the last stage’s stdout and every stage’s stderr, in stage order. When a later stage stops reading early (git log | head -1), the earlier stage is told to stop, and its exit does not count as a failure. Only exec needs a single command, because a pipeline cannot replace the process (use sh -c "a | b" | exec).

let newest = sh -c "printf 'b\na\n'" | sort | head -1 | line
print newest

exitCode is an error where its result would be captured or thrown away, and the error explains what to do:

sh -c "exit 3" | exitCode // a bare statement discards the code — bind or match it

line, text and exec

The last three rows use the same | spelling but aren’t about the exit code:

  • cmd | line — the single stdout line, trimmed, as a string. It raises on a nonzero exit, or if there are no lines or more than one: let sha = git rev-parse HEAD | line.
  • cmd | text — all of stdout as one string, with trailing blank lines dropped, like bash’s $(…). It raises on a nonzero exit: let notes = git log -1 --format=%B | text.
  • cmd | exec — replaces the weir process with the command (execve; on Windows weir runs it, waits, and exits with its code). The command keeps weir’s pid, so a container entrypoint gets signals directly. It never returns, so it can stand alone as a statement. It can’t take piped input and isn’t allowed inside plan.

All three accept an env overlay ($e(cmd | line)), and line and text accept piped input (xs | grep foo | line).

Chaining

cmd | and next runs next only if cmd succeeded, like bash’s &&; if cmd fails, that failure is an error, so the script stops there. cmd | or next runs next only if cmd failed, like ||; there the failure picks the branch instead of raising. Both stream output and return unit, and the right side is a whole command line, so a builtin such as cd works there:

sh -c "exit 1" | or echo "fell back"
echo built | and echo linked
  • One word per chain. a | and b | and c and a | or b | or c are fine; a | and b | or c is an error. For “if a, then b, else c”, write if a | succeeds then b else c (why mixing is refused: coming from bash).
  • The left of | or is an external command or a pipeline, which fails with its leftmost failing code. A builtin can’t be there: it raises instead of returning an exit code.
  • No reifier at either end. a | and b | complete would capture only b, and a | succeeds | and b has no exit left to branch on; capture on a separate line instead. | exec may end a chain, since it hands over.
if sh -c "exit 1" | succeeds then echo "took the then" else echo "took the else"

Signatures

weir check looks up every command named literally in a script. A declared signature (#sig tool, generated by weir add sig) extends the check to the tool’s flags. The details are on the tooling page.

Types

Scalars

  • int — 64-bit. Arithmetic overflow raises an error rather than wrapping around, while a range simply stops at the type’s limit, so every value it yields is correct
  • float — always finite: an operation that would produce NaN or Infinity raises an error, and == on floats is a check error that suggests Float.near
  • string
  • bool
  • unit — the value an effect returns, written ()

Nothing converts implicitly: 3 / 2 is integer division, and mixing int and float is a type error that suggests Float.ofInt:

print $"{3 / 2.0}" // no implicit widening; use Float.ofInt 3

% is integer remainder, truncated, so the sign follows the dividend (-7 % 3 is -1, as in F# and .NET; Python’s floored % gives 2). Dividing by zero raises an error, as with /. Floats are refused, because weir floats are always finite and IEEE remainder can produce NaN:

print $"{7 % 3} {-7 % 3}"
print $"{7.5 % 2.0}" // '%' is integer-only — Float.toInt one side

The unit-bearing scalars

Duration, Size, and Instant carry their unit in the value. Durations and sizes have literals (Lexical); instants come from Instant.now (), Instant.parse or Instant.parseWith. Arithmetic between values of the same type works where it makes sense. Arithmetic across types doesn’t exist, and the errors name the explicit conversions (Duration.toMillis, Size.parse, Instant.epochMs).

Uuid

A 128-bit identifier with its own type, not just a string. Uuid.v7 is time-ordered and strictly increasing within the process, so v7 ids sort by creation both as uuids and as their text; Uuid.v4 is random; Uuid.v5 is name-based and deterministic. Uuid.parse reads the hex-and-dash form, 32 bare hex digits, or a urn:uuid: prefix, in any case, and raises on anything else; Uuid.tryParse returns an Option.

let id = Uuid.v7 ()
print $"{id} is version {Uuid.version id}"
print $"{Uuid.v5 Uuid.ns.url "https://example.com"}"

Uuids compare for equality and sort (Seq.sort, Seq.min), by their big-endian bytes, which is the same order as their text. There is no <, since identifiers are not quantities. In JSON and YAML they are written as the lowercase string, and they splice into a command’s argv as that same text.

seq

A seq is lazy. Pipelines compute only what they need, and ranges are generated lazily, while [a; b; c] literals are ordinary eager values. Iterating a bound pipeline a second time runs its effects again, including external commands. Seq.freeze computes the values once and keeps them, and is the usual fix. The checker warns when a binding backed by a command is iterated in a second place without being frozen (possible re-enumeration; this is only a warning, and weir check still exits 0). Capturing a command gives a seq<string>, one element per line.

A let bound to a command, a pipeline of commands, ls or Path.glob holds a lazy value: nothing runs when it is bound, and each read runs it again, in the directory and environment current at that read; only a within cd/within env it was written inside is kept, however late the read (let top = ls, then cd "src": top lists src). These run at once instead: the reifiers (| line, | text, | complete, | succeeds, | exitCode, | orFail, | and/| or), Seq.freeze and any other function that reads the whole sequence, and a $(…) or $@xs in a command argument (argv is built immediately). The contexts (guide):

  • script: nothing runs until the value is used
  • REPL: the echo of a lazy let shows name : type (command-backed — runs on each use; type name to run it) and never runs it
  • init file and modules: a command, a pipeline of commands or a value piped into a command is allowed; the eager forms and passing the command to a function are refused, since nothing may run at load

List items are separated by ; (or by line breaks). A comma builds a tuple, so [1, 2] is a one-item list holding (1, 2); the checker warns about that shape and suggests [1; 2], or [(1, 2)] if the tuple was meant. A list of several pairs, ["a", 1; "b", 2], is fine.

xs[i] is Seq.item i xs and raises an error when out of range. The bracket must directly follow the name, because f [0] is a function call. xs[a..b] slices, inclusive and clamped to the available range (an out-of-range or reversed range gives an empty result rather than an error), and either end can be left open: xs[..b] / xs[a..]. A bounded slice stops reading a lazy source, so nats[0..4] terminates. A slice has the type of what you slice: a seq gives a seq, a string a string ("weir"[1..2] is "ei", counted in UTF-16 chars), and Bytes gives Bytes. ^n counts from the end (^n = length − n): xs[^1] is the last element, xs[..^2] all but the last, xs[^2..] the last two, xs[^3..^1] uses the end for both bounds. A ^ bound needs the length, so slicing a seq from the end reads the whole sequence; slices that count from the start stay lazy. There is no dotted xs.[i].

Tuples

(a, b) — two or more elements. A tuple type is just its element types; it needs no declaration. As soon as the parts need names, declare a record instead. fst/snd work on pairs only.

Records

Records are nominal and have a fixed set of fields: a record literal must name every field, and with copies a record with some fields changed but cannot add new ones:

type Score = { Name: string; Points: int }

let s = { Name = "a"; Points = 12 }
let s2 = { s with Points = 13 }
print $"{s2.Points}"
type P = { N: int }
let p = { N = 1 }
let q = { p with Extra = 2 } // record update cannot add fields
print $"{q}"

Two declared records with the same fields are different types. Anonymous record types like {| ip: string |} are the exception: two with the same fields are the same type, whatever the field order. They are meant for reading an external shape once (from json {| ip: string |}); for your own data, declare a record. Fields can take attributes (Lexical), which are read at check time and have no effect at runtime. A field whose JSON/YAML key is not a legal identifier gives the key with [<Wire "key">].

Unions

Each case can carry a payload; a payload of several values is a tuple. You construct a value by writing the case name. For an imported union, constructing a value needs the module name (X.Red), but a pattern names the case bare (| Red ->):

type Verdict =
    | Pass of int
    | Fail

let v = Pass 12

match v with
| Pass n -> print $"{n}"
| Fail -> print "no"

Option

Some x / None represent a value that may be missing. The try functions return them (Seq.tryFind, Str.tryToInt, Bytes.tryFromBase64), and you handle them with match or the Option module (defaultValue, map, orFail).

Map

Map<string, T> only allows string keys, matching JSON object keys. The functions are ofPairs (the last duplicate key wins), get (raises an error naming a missing key), tryGet, has, and pairs/keys/values (sorted by key). There is no m[k] indexing, and == is not defined:

let a = Map.ofPairs [("k", 1)]
print (show (a == a)) // '==' is not defined for Map<string, int>

Bytes and Secret

Bytes holds binary data. It is never converted to or from text implicitly — you read and write it with functions like File.readBytes and File.writeBytes — and anywhere it would be rendered as text the error names the function to use instead (the guide). It slices like a sequence — b[1..3], b[..2], b[3..], inclusive and clamped, giving Bytes — but has no single-element b[i]; use Bytes.sub start len to take a range, including a single byte (Bytes.sub i 1). Secret marks a credential so it is never shown by accident: show masks it, interpolation and serialization refuse it, Secret.reveal is the only way to get the text out, and splicing it into argv passes it as-is.

Functions

let f x y = … is curried, and you can partially apply any function. Bindings are generic where possible: let id x = x works on any type, and a function-typed parameter is inferred too, so let apply f x = f x has type ('a -> 'b) -> 'a -> 'b and can be called like any function:

let apply f x = f x
print (apply (fun n -> n + 1) 1)

The one place inference falls short: + on two values of unknown type can’t be inferred (int or string?), so pin one side down, for example with x + 0.

You can also write a function type — in a union payload, a record field, or a generic argument: Custom of (string -> bool), { matches: string -> bool }. -> is right-associative and binds more loosely than * and generics (int * string -> bool is (int * string) -> bool); a function-typed argument needs parentheses ((unit -> int) -> string). You can construct such a value and call the function inside it, but ==, to json, to yaml and show reject any type that contains a function anywhere, and the error names the field. So whether a record can be serialized depends on its fields: a record of plain scalars works everywhere.

Constraints

Equality, rendering and ordering each work through a built-in constraint. Constraints are inferred, never written out, and you cannot define your own (there are no user type classes). A helper like let same x y = x == y works on any type that supports equality, and using it with any other type is an error where it is called. Functions and seqs can’t be compared, and for floats the error suggests Float.near.

let same x y = x == y
print $"{same 1 1} {same "a" "b"}"

Patterns

Patterns appear in match arms (the | pattern -> … lines), let, for, and function parameters. A parameter is a plain identifier, (), a parenthesized tuple pattern (let dist (x, y) = …), or a record pattern (without parentheses: let label { names = n } = n). In these binding positions the pattern must always succeed; a pattern that can fail (Some x, a literal) is rejected there — use match or function instead.

type Crew = { names: string }
let label { names = n } = n
let swap (a, b) = (b, a)
print (label { names = "kestrel" })
print $"{swap (1, 2)}"

Variables and constructors: lowercase vs uppercase

A lowercase name in a pattern binds a variable. An uppercase name is a constructor. A mistyped constructor name would therefore bind a variable and silently match everything, so an arm that becomes unreachable because of it is an error with a did-you-mean:

type V =
    | Pass
    | Failing
match Pass with
| pass -> print "ok" // 'pass' binds — did you mean 'Pass'?
| Failing -> print "no"

Literals

Int, string, and bool literals match by equality:

let word =
    match 2 with
    | 0 -> "zero"
    | 2 -> "two"
    | _ -> "other"

print word

Literal arms alone never make a match complete; end with _ or a variable:

let t =
    match 1 with
    | 0 -> "zero"
    | 1 -> "one" // literals never complete a match: add a _ or var arm
print t

Wildcard

_ matches anything and binds nothing. _ also has two expression shorthands — _.field and _[i] — which are lambdas, not patterns.

Tuple patterns

(a, b) destructures a pair; the pattern must have as many elements as the tuple. It works in let too:

let host, port = ("db", 5432)
print $"{host}:{port}"

fst and snd work on pairs only; a longer tuple is a type error:

print (show (fst (1, 2, 3))) // expected 'a2 * 'a3, got int * int * int

Constructors

A case name matches that case, and a nested pattern destructures its payload, including tuple payloads. Patterns can be nested to any depth:

type C = { names: string }

type W =
    | Wrap of C
    | Empty

let n =
    match Wrap { names = "api" } with
    | Wrap { names = n } -> n
    | Empty -> "-"

print n

Record patterns

A record pattern can name any subset of fields; the rest are ignored. Field names keep their declared capitalization, while the names you bind are lowercase:

type Container = { State: string; Names: string }

let r =
    match { State = "up"; Names = "api" } with
    | { State = "up" } -> "running"
    | _ -> "not running"

print r

A field may hold a literal, so one arm can both filter and destructure. Such a pattern can fail to match, so it never makes a match complete on its own:

type St = { state: string }
match { state = "up" } with
| { state = "up" } -> print "x" // a refutable record arm needs a catch-all below it

There is no punning — { names = n }, never { names }:

type Pn = { names: string }
let { names } = { names = "x" } // no punning: bind explicitly, { names = n }
print "unreachable"

There is also no {| |} pattern. Plain braces destructure a record whether its type was declared or anonymous:

let f {| id = i |} = i // no {| |} patterns — the plain brace spelling destructures anonymous shapes too
print (f 1)

Guards

Alternatives share one arm: | "a" | "b" -> …, | Debug | Info -> …. Each alternative behaves as its own arm with the same guard and body, so a union’s alternatives count toward exhaustiveness. An alternative cannot bind a name yet — | (n, 0) | (0, n) -> n is an error; write one arm per alternative. An arm takes at most 64 alternatives; for a longer list, test membership in a guard: | x when Seq.contains x values -> ….

type Level = Debug | Info | Warn | Error
let loud l =
    match l with
    | Debug | Info -> false
    | Warn | Error -> true
print $"{loud Info} {loud Error}"

when adds a condition to any arm. An arm with a when guard never counts toward exhaustiveness:

let tier =
    match 42 with
    | 0 -> "empty"
    | n when n > 100 -> "huge"
    | _ -> "ordinary"

print tier

The Regex pattern

Matches a regex and binds its captures in one arm. The regex must be a raw string (@"..." or """..."""). It is compiled at check time, so an invalid regex is a check error, and the tuple after it must have exactly as many names as the regex has capture groups. Groups are bound as strings:

match "cache=42" with
| Regex @"(\w+)=(\d+)" (key, count) -> print $"{key} -> {count}"
| _ -> print "unparsed"

function

function is a lambda that matches on its argument — short for fun x -> match x with:

["cache=42"; "noise"]
    |> Seq.choose (function
        | Regex @"(\w+)=(\d+)" (k, v) -> Some $"{k}: {v}"
        | _ -> None)
    |> Seq.iter print

Exhaustiveness

A match that doesn’t cover every case is an error, not a warning. Likewise, an arm that can never be reached because a catch-all comes before it is an error. When matching on a union, listing every case makes the match complete; literal arms and record arms that can fail never do.

Interpolation and rendering

Holes

$"…{expr}…" inserts the text of expr into the string. A hole renders anything show can, records and seqs included. Functions cannot be rendered:

type Score = { Name: string; Points: int }
let s = { Name = "a"; Points = 12 }
print $"{s} and {[1; 2; 3]}"
print $"{fun x -> x}" // 'a1 -> 'a1 cannot be shown (functions never render)

{{ and }} are literal braces. A comment cannot go inside a hole. The raw interpolated form $"""…""" has holes but no escapes, and each line of a $<<< heredoc block follows the same hole rules, with $ still an ordinary character. The $$<<< splice heredoc uses a different marker: $name/${expr} substitute and braces stay literal (Lexical).

Generic holes

A hole that uses a parameter makes the function generic, just as show would: the function accepts any value a hole can render, and a function that reads fields works on any record that has them.

let dash n = $"-{n}"
print (dash 5 + dash "x")

type Spec = { name: string; port: int }
let addr s = $"{s.name}:{s.port}"
print (addr { name = "a"; port = 1 })

The one exception is Secret: passing one to such a function is an error at the call, just as it is in a hole written directly (a Secret nested inside a record is rendered masked). A hole whose type cannot be generalized, because the statement it is in does not bind a name, defaults to string.

show

show x produces the same text as a hole, as a plain string. Use it where a hole doesn’t fit: as a function value (Seq.map show), and for a Secret, which show masks as *** while interpolation refuses it.

Rendering is meant for reading, not for exchanging data: strings inside rendered structures are quoted, and long seqs are truncated. Use print for raw output and to json for data other programs will read.

Two values render as summaries

A Secret always renders as ***. A Bytes value renders as its size (<12 B>), never its content, since raw bytes can garble a terminal. Where either is refused, the error names the function to use instead (Secret.reveal; Bytes.toBase64 / File.writeBytes).

Scopes

within holds a resource for the length of a block and releases it however the block exits: normal completion, an error, exit n, SIGINT or SIGTERM. This works both in a terminal and detached: a kill -INT/kill -TERM sent to a setsid or backgrounded weir cleans up the same way, exiting with 130/143. The cleanup runs on the script’s own thread, and no statement after the signal runs: a sleep or a wait on a scoped process is cut short, and the block unwinds from there. A reader closing weir’s stdout (weir s.weir | head -1) ends the script the same way, exiting with 141 (bash’s SIGPIPE status) and no message. A second signal during cleanup exits immediately, so pressing Ctrl+C twice gets you out. There are two exceptions, both by design. kill -9 of weir itself skips cleanup (only a lock is still released, by the kernel). And a weir started in the background from an interactive shell (weir … &/nohup, which keeps a controlling terminal) starts with SIGINT ignored, following the usual job-control convention, so Ctrl+C in the terminal does not reach it; send it SIGTERM, or kill -INT its pid directly. The block is an ordinary block: its statements run and the last expression is its value. It also works as a plain statement.

formholdson every exit
within tmp da fresh directoryremoves it
within cd "path"the working directoryrestores it
within env varsan env overlay for child spawnsdrops it
within … alwaysnothing — a body plus cleanupruns the always block
within lock "path"an advisory file lockreleases it — the kernel does, even on kill -9
within proc h = cmda background processkills and reaps its tree
within serve s = cfg handleran HTTP listenercloses the socket — the port frees

tmp

Creates a fresh directory and binds its path. It is fine for the block to remove the directory itself before it exits:

let digest = within tmp d
    ["payload"] |> File.write $"{d}/f.txt"
    Str.sha256 (File.read $"{d}/f.txt" |> Str.join "-")

print digest[..11]

cd

Runs its block in the given directory and restores the previous one however the block exits. A missing directory is an error, raised before the block runs, that shows the absolute path.

env

Adds environment variables for every process started in the block. Inside the block, Env.get, Env.vars and Env.load see the overlay too, as pwd sees within cd; after it, they read the unchanged process environment. Nested overlays combine, with inner values winning, and an env given directly on a capture ($e(...)) wins over any enclosing within env:

let vars = [Env.pair "GREETING" "scoped"]

within env vars
    sh -c "echo child sees $GREETING"

print (Env.get "GREETING" |> Option.defaultValue "parent stays clean")

The variables can also be written right after within env, as you would for one command (NAME=value cmd):

let stage = "prod"
within env STAGE=$stage REGION=eu-1
    sh -c "echo $STAGE in $REGION"

A body of one command can go on the same line: within env A=1 make, within cd "src" make.

Bare within and always

Holds no resource; the always block runs however the body exits. When both the body and the cleanup fail, the body’s error is the one raised, the cleanup’s failure is printed to stderr with a marker, and cleanup of the enclosing scopes continues:

within tmp d
    within lock $"{d}/demo.lock" timeout=10s
        within
            print "one holder at a time"
        always
            print "released either way"

always after any kind

Every kind of within can take a trailing always. It runs inside the scope while the resource is still held, and the resource is released afterwards. So the cleanup still sees the tmp directory, runs in the cd directory with the env overlay, holds the lock, and finds a proc or serve still running (they are stopped after it). The bound name is still in scope:

within tmp d
    ["report"] |> File.write $"{d}/report.txt"
always
    cp $"{d}/report.txt" report.txt

It behaves exactly like a bare within … always nested inside the scope, so all the rules above apply.

lock

An advisory file lock. It waits for the lock by default; with timeout=30s it raises an error if the time runs out. It works across processes and across parallel pmap workers.

proc

Starts a background process and binds a handle to it. However the block exits, the process and its children are killed and cleaned up. Several scoped processes are stopped in reverse order of starting. A child’s own exit status is a value (Proc.wait), not an error. The guide covers the details: watch=, output files and Proc.tail. A process that must outlive the script is a daemon and belongs under systemd; weir has no nohup.

serve

An HTTP listener held for the block. within serve srv = { port = 8080; maxConcurrent = 4 } handler opens the socket on loopback, runs handler — a plain synchronous HttpServerRequest -> HttpServerResponse function — once per request, and closes the socket however the block exits (normally, by an error, SIGINT, SIGTERM), so the port is freed. As with proc, the server lives exactly as long as the block, and there is no stop function. Server.port, Server.running and Server.streamErrors are all you can do with the handle.

The listener answers on the same port for all the common loopback names — 127.0.0.1, localhost and [::1] — so a client using any of them reaches the handler ([::1] is skipped quietly on a host without IPv6 loopback). It only listens on loopback and never binds all interfaces, so it can’t be reached from other machines; put a reverse proxy in front of it for a public address.

The handler routes on req.path with an ordinary match; there is no routing framework:

let handler = fun req ->
    match req.path with
    | "/health" -> HttpServerResponse { status = 200; headers = []; body = Text "ok" }
    | _ -> HttpServerResponse { status = 404; headers = []; body = Text "not found" }

The response body is an HttpBody, shared with the Http client — NoBody, Text, Json, Form, or Stream of seq<string>. A Stream body is written chunked and flushed per element (SSE-shaped: one event per element, one data: line per line of it, so a newline cannot forge an event), so a lazily produced seq streams as it goes: the client sees the first elements before the sequence is finished. If a Stream producer raises partway through, the error is not raised out of the handler. Instead it is recorded on the handle, and you read it with Server.streamErrors srv (the response is aborted, so a careful client can tell it was cut short). maxConcurrent limits how many handlers run at once, as with Seq.pmapWith; extra requests wait in a queue.

The config record is { port; maxConcurrent }, optionally with bodyTimeout (a Duration, default 30s) that limits how long reading the request body may take, and maxBody (a Size, default 16MiB) that limits its size. A client that sends its body too slowly gets a 408 instead of tying up a handler; a body larger than maxBody gets a 413 and is never buffered whole.

HttpServerRequest carries:

  • method — an HttpMethod. A valid method the union has no case for arrives as Other of string (match it with | Other v ->), QUERY arrives as Query, and a malformed method gets a 400 before your handler runs
  • path
  • query — the raw string without the leading ?; split it with Str.trySplitOnce
  • headers — pairs, in the order received. The underlying listener keeps only the last value of a repeated header, so a proxied X-Forwarded-For chain shows only the last hop
  • body — the request text

Deliberately not supported in v1:

  • TLS — put a reverse proxy in front
  • a routing DSL — the match is the router
  • WebSockets
  • request-body streaming
  • HTTP/2

Boundaries: argv and env

Command-line arguments and environment variables load the same way: declare a record, load it once, and use the typed values from then on. Loading is strict and reports every problem at once, in a single error, before the script does anything.

Args.load

Field names become kebab-case flags (dryRun → --dry-run), plus a first-letter short flag where that is unambiguous. A bool field is true when the flag is present. string, int and the other scalars are required, and Option makes a field optional. The first line of the field’s /// comment is its --help text:

type Cli = {
    [<Short "C">]
    /// clean the target first
    clean: bool

    port: Option<int>
}

let cli = Args.load Cli
print $"{cli.clean} {cli.port}"

[<Short "c">] sets a short flag ("h" is reserved for --help), and [<NoShort>] removes one. These problems are all reported together:

  • unknown flags (with a did-you-mean)
  • unexpected arguments
  • missing required flags
  • unparseable values

--help prints the usage even when the rest of the command line is invalid.

There are no positional arguments; pass operands as flags. To parse arguments by hand, Args.flag and Args.value scan the raw Self.args.

Subcommands

Declare subcommands as a union whose cases carry records. The first argument picks the case, the rest are parsed as that case’s flags, and the checker makes sure your match covers every case. Flags shared by all subcommands go once on a containing record, and may appear before or after the subcommand name:

type CloneArgs = { remote: string; force: bool }
type Cmd = Clone of CloneArgs | Status
match Args.load Cmd with // no argv here: "missing subcommand; one of: clone, status"
| Clone a -> print a.remote
| Status -> print "status"

Defaults

[<Default v>] supplies a value when the flag is absent; the field stays non-Option, and --help shows the default. On a bool, [<Default true>] adds a --no-x flag to turn it off. [<Default false>] is rejected on a bool, since a missing flag is already false. The attribute takes literals only; for a computed default, keep the field an Option and fill it in with one line of code.

Env.load

The same approach works for environment variables. Field names match variable names exactly, with no case conversion (use [<Wire "NAME">] for a name that is not a legal identifier). [<Default>] fills in missing variables, and a Secret field is the standard way to take in a token. Env bools are read from text (FLAG=false), not from whether the variable is set.

When a variable has a fixed set of allowed values, declare it as a union of cases without payloads. Matching is case-insensitive (env values are usually uppercase), and an unknown value is an error that lists the allowed ones with a did-you-mean:

type Level =
    | Debug
    | Info

type LogCfg = { REF_BOUND_LEVEL: Level }

["REF_BOUND_LEVEL=debug"] |> File.write "ref-bound.env"
let e = Env.fromFile "ref-bound.env"
within env e
    sh -c "echo level=$REF_BOUND_LEVEL"
print "declared sets beat stringly config"

Env.get "NAME" reads one variable as Option<string>. Env.fromFile reads a subset of dotenv (KEY=VALUE, quotes, # comments; no export and no $VAR expansion).

What both refuse

Both refuse Bytes fields, and the error names the conversion to use. Both parse Instant fields as ISO 8601. A Secret passed as a flag is visible in ps output; weir does not hide argv.

Adapters

from reads a data format into a type you declare; to writes one. Three formats work in both directions (json, jsonl, yaml), and three more are read-only (toml, xml, table). You always say which format you mean: from json T reads one document however many lines it spans, while from jsonl T reads one document per line (blank lines are skipped) and yields seq<T>.

type Peer = { host: string; port: int }

let peer = ["{\"host\": \"a\", \"port\": 9000}"] |> from json Peer
print $"{peer.host}:{peer.port}"

let peers = ["{\"host\": \"a\", \"port\": 1}"; "{\"host\": \"b\", \"port\": 2}"] |> from jsonl Peer
print $"{peers |> Seq.length} peers"

Writing mirrors reading. to json writes one minified document: a record becomes an object and a seq becomes an array (built in memory all at once, since a single line cannot be streamed). to jsonl writes NDJSON, one document per element, lazily. Strings escape only what JSON requires (", \, control characters); <, &, ' and non-ASCII text are written as-is, characters beyond the BMP as a \uXXXX surrogate pair. Each format reads back what it writes: to json |> from json T, to jsonl |> from jsonl T.

type P = { a: int }
{ a = 1 } |> to json |> Seq.iter print
[{ a = 1 }; { a = 2 }] |> to json |> Seq.iter print
[{ a = 1 }; { a = 2 }] |> to jsonl |> Seq.iter print

(prints {"a":1}, then [{"a":1},{"a":2}] — one array document — then the two NDJSON lines.)

Which field types are allowed

A field is one of:

  • a scalar (int, float, string, bool)
  • an Option of one of these types
  • a record whose fields are all of these types
  • a seq of one of these types
  • a Map<string, T>

The rule applies recursively. A self-referential record is a check error that names the cycle. For a top-level JSON array, say so in the type: from json seq<Peer>. Integer-looking JSON numbers are accepted by float fields (JSON has only one number type).

A missing array is an error, not a silent []; use Option for fields that may be absent. Unknown keys are ignored on read. A key that is not a legal weir identifier is mapped with [<Wire "key">], and two fields that map to the same key are an error at the declaration.

Documents in several shapes

A tagged union reads documents whose shape depends on one field. [<Tag "kind">] on the union names that field. Each case carries a declared record, or nothing for a document that has only the tag. The tag value defaults to the case name; [<Wire "v1">] on a case overrides it. Tagged unions work in JSON and YAML, at the top level or nested, so from jsonl KDoc reads mixed NDJSON and from json seq<KDoc> reads a mixed array. Adding one [<Other>] case (of string, or with no payload) makes the union accept unknown tags: they land in that case instead of raising an error. A document with no tag field is always an error, since that is malformed rather than unknown. When writing, the tag is written first, and an [<Other>] value cannot be written. Unions without a tag are not supported; the error suggests [<Tag>].

Keys that are data

Map<string, T> reads an object keyed by IDs, either as a field or as the whole document. Keys must be strings, entries are iterated in key order, and for duplicate keys the last one wins. to json writes the object back.

YAML

from yaml T accepts the same shapes as JSON. Quoting decides a scalar’s type in both directions: rate: 1.5 is a number, "1.5" is a string. An empty value and the plain words null, Null, NULL and ~ are null (YAML 1.2’s core schema): None in an Option field, an error in a required one; "null" is a string. A quoted scalar may continue on more deeply indented lines, as in kubectl’s long message: values:

  • the closing quote ends it
  • each line break folds to a single space
  • an empty continuation line becomes a newline
  • the folded value stays a string

Literal block scalars (key: | and key: |-) read as strings. An indentation indicator such as |2 or |2-, which kubeconfigs sometimes carry, sets the content’s indentation relative to the key, so the first line may keep leading spaces. Folded scalars (>) and |+ are not supported.

from yaml stream T reads a ----separated stream of documents, each as T. To read a mixed bundle such as a Kubernetes apply file, use from yaml stream KDoc with a tagged union: stream means “many documents”, and the union picks the shape of each one. An empty stream gives zero documents.

Writing mirrors reading exactly. to yaml writes one document: a record becomes a mapping, a seq a sequence document, and a seq of pairs one mapping. to yaml stream writes one document per element. Each form reads back with the matching reader (to yaml |> from yaml seq<T>, to yaml stream |> from yaml stream T). A multiline string is written as a block scalar. The yaml template literal (with checked structure, splices as nodes, and schema=) is part of the language and is covered in the guide; vendoring schemas is on the tooling page.

TOML

from toml T reads one TOML document into T — a Cargo.toml, a pyproject.toml, a tool’s config. It reads TOML 1.1, so every TOML 1.0 file too, and it is read-only: there is no to toml. The document is a table, so T is a record (or a tagged union); seq, stream and Map tops are refused. Inside, the field types follow YAML’s rules: tables are records, arrays and arrays of tables ([[bin]]) are seqs, a key that may be missing is an Option, and [<Wire "key">] names a key that isn’t a weir name, such as requires-python.

  • Integers in any TOML spelling (1_000, 0xff, 0o7, 0b1) read as int; inf and nan parse, but reading one into a float field is an error, since weir floats are finite.
  • A date or time is read as its text. An offset date-time such as 2024-03-01T10:00:00Z reads into an Instant field marked [<Iso8601>].
  • Duplicate keys and tables defined twice are errors, reported with the line, as the TOML spec requires.
type Package = { name: string; version: string }
type Cargo = { package: Package; bin: seq<{| name: string |}> }

let cargo = <<<
    [package]
    name = "demo"
    version = "0.3.1"

    [[bin]]
    name = "cli"

let c = cargo |> from toml Cargo
print $"{c.package.name} {c.package.version}: {c.bin |> Seq.map _.name |> Str.join ","}"

XML

from xml T reads one XML document into T. It is read-only and supports a subset of XML. The document’s root element maps to the top-level record. A field matches a child element by local name (a default xmlns, like MSBuild’s, is stripped so field names stay plain). [<Attr>] (or [<Attr "Include">]) reads an attribute. [<Elem "ProjectReference">] names the repeated child element that a seq< > field reads; by default that is the element type’s name for seq<record> and the field name for seq<string>. A nested record reads a child element recursively.

type Ref  = { [<Attr>] Include: string }
type Pg   = { IsPackable: Option<string> }
type Proj = { [<Elem "PropertyGroup">] groups: seq<Pg>
              [<Elem "ProjectReference">] refs: seq<Ref> }
let proj =
    [ "<Project>"
      "  <PropertyGroup><IsPackable>false</IsPackable></PropertyGroup>"
      "  <ProjectReference Include=\"../Core/Core.csproj\" />"
      "</Project>" ]
    |> from xml Proj
print $"{Seq.length proj.refs} refs, {Seq.length proj.groups} property groups"

Every XML leaf is text, so a field is string, Option<string> (a present-or-absent element or attribute), a record, or a seq of a string or record — nothing else. A numeric or boolean field is declared string and converted (Str.toInt); the checker tells you this rather than guessing at a convention XML does not define. [<Attr>] fits only string/Option<string>, [<Elem>] only a seq, and the top level is a single root element, so there is no from xml seq<T> (repeated children are read with a seq< > field). There is no to xml.

Aligned tables

from table T reads aligned column output, the kind kubectl and docker print (one header row followed by aligned data rows), into declared row records, yielding seq<T>. The first non-blank line is the header. Columns are cut at the header positions, not at whitespace, so a value containing spaces (Up 2 hours, a free-text last column) stays intact. Headers are separated by two or more spaces; a single space stays inside a header, so CONTAINER ID is one column (both tools pad columns with three spaces).

type Pod = { name: string; status: string; restarts: int; node: Option<string> }
let pods =
    [ "NAME    STATUS    RESTARTS   NODE"
      "web-1   Running   0          k3d-a"
      "db-0    Pending   3          <none>" ]
    |> from table Pod
pods |> Seq.iter (fun p -> print $"{p.name}: {p.status} ({show p.restarts} restarts)")

A field matches its header by name, ignoring case and anything that is not a letter or digit: name reads NAME, podTemplateHash reads POD-TEMPLATE-HASH. [<Wire "HEADER">] matches a header exactly as written. Cells are trimmed and converted to the field’s type (string, int, float, bool). An Option field reads an empty cell, or a cell that is exactly <none> (as kubectl prints), as None; an empty cell in a required field is an error that suggests making it an Option. Extra columns are ignored (as with unknown JSON keys), blank lines are skipped, and a table with only a header is an empty seq (docker ps with nothing running). Errors say where: a missing column is named along with the headers that were found, and a cell of the wrong type is reported with its row and column. The result is already a seq of rows, so you don’t write seq/stream/Map around the type. There is no to table.

az … -o table (and other tabulate-style tools) draw a dashes rule line under the header (------ ----------). from table skips that separator when it is the first data row, so az output reads without a garbage row and #infer types from the real rows. Tables with no separator (kubectl, docker) are unchanged; only a row that is entirely dashes and spaces is dropped, and only in first position.

#infer <src> from table as Pod (or sample |> Table.inferShape) drafts the row record from a real sample. It infers each column’s type, uses Option where a column has empty or <none> cells, and notes that the value reads as seq<Pod>.

Editing YAML: Yaml.parse and yaml patch

from yaml T reads into a declared record and drops every field you did not declare, so it is the wrong tool for read-modify-write. For that, use the untyped pair, which keeps the whole document. Yaml.parse reads one document into Yaml nodes (scalars get their type from their text, exactly as in a yaml block), and Yaml.merge applies a yaml patch block. The patch’s structure says where each change goes — kustomize’s strategic-merge model, with no path language. Maps are merged recursively, adding or updating keys. A sequence item is appended if absent, or, with by=<key> on the marker line, updated by that key (and inserted if no item matches). Scalars are replaced. The $- marker removes things: in value position it removes the key it sits under, and as - $- <content> it removes the matching item. The order of changes does not matter and applying a patch twice gives the same result, so with by= you never write an “update or insert” branch yourself.

let doc = <<<
    kind: Kustomization
    images:
        - name: app
          newTag: v1
|> Yaml.parse
let p = yaml patch by=name
    images:
        - name: app
          newTag: v2
doc |> Yaml.merge p |> to yaml |> Seq.iter print

A patch has type YamlPatch, not Yaml, and the type enforces the rules. to yaml on a patch is a check error, because a patch is a set of instructions rather than a document, so a $- removal marker can never end up in a file. $- outside a yaml patch block is an error, and patch cannot be combined with schema= (a patch is partial, while schemas validate whole documents). Yaml.parse never produces a removal marker; parsed text is always plain data. There is no function that edits a file in place. You write the round-trip as a pipeline, File.read f |> Yaml.parse |> Yaml.merge p |> to yaml |> File.write f, so the change stays visible.

Which types serialize automatically

Types with one obvious encoding are serialized automatically. Types that could be encoded in more than one way are refused, and the error names the explicit conversion. JSON and YAML follow the same rule in both directions, with the same messages.

  • int, float, string, bool and Uuid work directly. A Uuid is written as its lowercase string and read back with any form Uuid.parse accepts; a malformed string raises an error naming the field. A JSON-lines log keyed by Uuid.v7 stays sortable by id as text.
  • Instant, Duration and Size are refused unless you say how to encode them: an epoch number and a text form are both reasonable, and weir won’t guess which one the other side expects. Put a codec attribute on the field, or convert explicitly; the error message offers both.
TypeCodecsWritten as
Instant[<Iso8601>], [<EpochMs>], [<EpochSec>]ISO 8601 string; epoch ms; epoch seconds
Duration[<Millis>], [<Seconds>]integer ms; integer seconds
Size[<ByteCount>]integer bytes
type Event = {
    [<Iso8601>] at: Instant
    [<Millis; Wire "elapsed_ms">] took: Duration
}
let line = [{ at = Instant.parse "2026-08-14T12:00:00Z"; took = 1500ms }] |> to jsonl |> Seq.head
print line
let back = [line] |> from jsonl Event |> Seq.head
print (show back.took)

One declaration covers both directions, so the writer and the reader cannot disagree. A codec applies to a T, Option<T> or seq<T> field, and it must fit the field’s type; this is checked where the record is declared. [<EpochSec>] and [<Seconds>] raise an error on a value with sub-second precision rather than silently dropping it. Codecs affect only to/from json, jsonl and yaml; show and == ignore them.

  • Bytes is refused; the error suggests Bytes.toBase64.
  • Secret is always refused. Credentials are never serialized, in any encoding.

Anonymous shapes at the boundary

For a shape you read only once, you can write the type inline:

let n = ["{\"count\": 3, \"noise\": true}"] |> from json {| count: int |} |> _.count
print $"{n}"

Declared records are distinct types even when their fields match. Two anonymous shapes with the same fields are the same type, whatever the field order.

Modules and imports

What a module is

A file that starts with module (alone, or module Name) is a module. It can be imported, it contains only declarations (type and let; no commands and no bare expressions), and it cannot be run on its own. A module’s let cannot run a command when the module is imported (| line and the other reifiers, |> Seq.freeze, passing the command to a function); wrap the command in a function, and it runs when a script calls that function. A plain command value (let log = git log -n 10, or a pipeline of commands) is allowed: it is lazy, so importing runs nothing and each use runs it again. A module’s top-level let values are evaluated when the importing program runs, never by weir check, which never evaluates anything.

Import forms

import comes first in the file, before any declarations. How a path resolves depends only on how it is written, never on which files exist:

  • import "./lib/x.weir" as X — a file path, relative to the importing file’s directory (lib.weir and names without an extension work too), or an absolute path. If the importing file is a symlink, its real location counts: a script linked into ~/bin imports the files beside the original
  • import "weir:name" as N — a vendored module: weir looks upward for a .weir/ directory and loads .weir/modules/name.weir (tooling)
  • as is optional — the alias defaults to the module’s declared name, or the capitalized filename
let modSrc = <<<
    module RefMod

    /// doubles
    let twice : int -> int

    let twice n = n * 2

let useSrc = <<<
    import "./refmod.weir" as M

    print $"{M.twice 21}"

modSrc |> File.write "refmod.weir"
useSrc |> File.write "use-refmod.weir"
weir use-refmod.weir

The signature is the export

A member is exported by declaring its signature — let twice : int -> int, with no = — above its implementation. Members without a signature are private to the module: their types are inferred inside the module, importers can’t see them, and using one from an importer is a check error that shows the signature to add (with the inferred type). The implementation needs no type annotations, because it is checked against the signature. A parameter can therefore pattern-match on its declared union type, and a generic signature ('a -> 'a) is enforced: an implementation that is less general than its signature is an error. The signature must come before the implementation, and a signature with no implementation is an error. Put /// docs on the signature line, where the API is documented. type declarations are always exported. Scripts can’t use signatures; in a script, types are always inferred.

Qualified access, and what never leaks

Imported values are always qualified: X.helper. Imported types can be used by their plain name — Ctx { field = v }, from json Ctx — and a record can also be built with the qualified X.Ctx { field = v }. An imported union’s cases are qualified when you construct a value (X.Red) but named bare in a pattern (| Red ->), and a local declaration always takes precedence over an imported name. A module exports only its own declarations, not what it imported.

The graph

Imports are transitive. A module imported by two others is checked only once. An import cycle is a check error that shows the loop, and a module cannot import itself. Paths are resolved at check time, from the path as written; nothing is looked up at runtime, and a missing file is an error at the import that shows the resolved absolute path.

Script-only

import needs a file to resolve paths against, so it is not allowed in -e or at the REPL prompt; the error explains why. The REPL’s init file is a file, so it can import.

Capabilities travel

check --can follows imports transitively: a module’s commands, file writes and network access appear in the importing script’s report, located at the module’s own file:line.

Loops and parallelism

There is no while, and no unbounded loop in any form. Every loop has a limit: retry (a number of attempts), poll (a time limit), for (a seq), and the parallel fan-outs.

retry and poll

Both have the same form: key=value options after the keyword, a block whose last statement is its value, and an optional until section that binds that value and tests it. If the block’s value is a bool, that is the success condition, and the whole form returns unit:

retry attempts=3 delay=100ms
    weir -e "print 1" | succeeds

To keep the successful attempt’s output, yield a value and bind it in until:

let out = retry attempts=3 delay=100ms
    let r = weir -e "print 42" | complete
    r
until r
    r.exitCode == 0

print (out.stdout |> Seq.head)

poll timeout=5m interval=10s has the same form but is limited by time; it is the loop for waiting until something is ready. watch=<proc handle> makes it fail immediately if the watched child process dies. When attempts or time run out, the error states the attempts made and the time elapsed. The options are an ordinary record, so you can compute and share them: let fast = { Retry.defaults with attempts = 3 }, then retry fast.

Seq.pmap / Seq.piter

These run a function over a seq in parallel. Results come back in input order, every item is processed, and if any fail, the error from the earliest item (in input order) is raised once all have finished:

[30; 10; 20] |> Seq.pmap (fun ms -> ms * 2) |> Seq.freeze |> Seq.map show |> print

Each worker gets its own copy of the session, so a cd or env change inside one only affects that worker and is gone afterwards. At most 64 run at once. The limit protects system resources rather than matching CPU count, since the work is usually I/O-bound. Seq.pmapWith / Seq.piterWith set the limit explicitly, and a value below 1 is an error:

[1] |> Seq.pmapWith 0 (fun x -> x) |> Seq.freeze // degree must be >= 1

There is no async/await, and there won’t be: concurrency in weir comes from processes and pipelines. A task that really needs async belongs in full F#.

Background processes

within proc h = cmd runs a background process for the length of a block. The process and its children are killed and cleaned up however the block exits. poll watch=h catches a process that crashes on startup. The child’s output goes to files (Proc.tail reads the last lines), and its exit status is a value you get from Proc.wait rather than an error. The guide walks through an example with a running server, and the scopes page covers how scopes work.

Errors and diagnostics

Check, then run

weir check reports all diagnostics at once, each with a location and a code, without running anything. A missing command is a warning at check time (exit 0, so you can still edit scripts for tools you haven’t installed) and an error at run time:

["nosuchcmd --flag"] |> File.write "ref-missing.weir"
let c = weir check ref-missing.weir | complete
print $"check exits {c.exitCode}"
let r = weir ref-missing.weir | complete
print $"run exits {r.exitCode}"

weir check --json emits the same diagnostics as an array of {file, line, col, endLine, endCol, severity, code, message} — for editors, CI gates and agent loops.

Which error you see

When a line has more than one thing wrong, you see one error: the one at the furthest point the parser reached. A later mistake cannot hide an earlier one, and separate broken statements each report their own error. The position is always a real spot in your file; a hint may replace the message, but never moves the position. check, check --json and the LSP all report the same error.

An error while the script runs is reported as file:line:col too, at the statement that failed — inside an if, match, for, within or retry body, a pipeline, or a lambda, not at the block’s first line. A failed command points at the command itself, even when its output is only read a few lines later. Other errors (fail, a builtin) point at the start of the failing line. A failure inside a function reports the line in the function, then where it was called from, one line per call, innermost first:

lib.weir:7:5: error: command failed with exit code 9: sh -c exit 9
  called from deploy.weir:4:1

Inline lambdas and for bodies add no called from line; they are part of the statement they’re written in.

Raising

A failing command raises an error when its output is read; a reifier turns the failure into a value instead (commands). Builtins raise errors with a location and a readable message (File.read: no such file: …). There is no try/catch block. A resource that needs cleanup goes in a within scope, which releases it however the block exits.

To turn a failure into a value, end an expression with |> try — the expression twin of | complete:

  • e |> try is Option<'T>: Some value, or None if evaluating e raised.
  • e |> try result is Result<'T, string>: Ok value, or Error with the message. Result has map, defaultValue, toOption and isOk.
  • Everything to the left of |> try is captured (|> groups left to right), and the value is fully evaluated inside, sequences included, so a failure can’t surface after try returns. An infinite sequence on the left never finishes.
  • exit still exits, and weir’s own internal errors are not caught. Effects that happened before the failure are not undone.
  • try is only valid after |>.

A script’s own union cases take precedence over the prelude’s: a type Status = Ok | Error declared in the script keeps constructing its own Ok, while a match on a try result value still sees Result’s cases.

fail and exit

fail "reason" stops the script with a located error and exit 1. exit n exits silently with a specific code, which is how you pass on a child process’s failure:

let r = sh -c "exit 3" | complete
if r.exitCode <> 0 then print $"would exit {r.exitCode}"

Log and log levels

Log.trace/debug/info/warn write levelled diagnostics to stderr; WEIR_LOG sets the level for one run (off silences them). There is deliberately no Log.error, because an error message should never be hidden by an environment variable. For a message that must always appear, use printerr; to stop, use fail. Stdout is exactly the same at every log level:

let e = Env.ofPairs [("WEIR_LOG", "off")]
within env e
    weir -e "Log.info \"hidden\" ; printerr \"loud\" ; print 1"

Warnings

Warnings never stop a run; they point out a likely mistake and carry on. Examples are a missing command at check time, > passed as a literal argv word, and ; in a command line. Each warning says what to use instead.

Modules

Args

Args.flag

Args.flag (name: string) : string -> bool

True when a --flag (or its short form) is present in argv.

Args.flag "verbose"

Args.value

Args.value (name: string) : Option<string>

The value of a --name option as an Option.

Args.value "name"

Bytes

Bytes.fromBase64

Bytes.fromBase64 (s: string) : Bytes

Decode standard base64 (padded or unpadded) to Bytes. Raises on malformed input. Use it for binary data; Str.fromBase64 decodes to text.

Bytes.fromBase64 "iVBORw0KGgo="

Bytes.fromHex

Bytes.fromHex (s: string) : Bytes

Decode hex (upper or lower case) to Bytes. Raises on odd-length or non-hex input. Handy for crypto values such as fingerprints, moduli and EC points, which are usually written in hex.

Bytes.fromHex "0a1B" |> Bytes.toHex

Bytes.hmacSha256

Bytes.hmacSha256 (key: Bytes) (msg: Bytes) : Bytes

Compute an HMAC-SHA256 of a message with a key (key first), returned as Bytes. To use a Secret as the key, pass Secret.reveal s |> Str.toUtf8; revealing a Secret is always explicit.

Bytes.hmacSha256 (Str.toUtf8 "key") (Str.toUtf8 "msg") |> Bytes.toHex

Bytes.length

Bytes.length (b: Bytes) : Size

The byte count as a Size.

Bytes.sha256

Bytes.sha256 (b: Bytes) : string

The SHA-256 digest of the bytes, as lowercase hex (same output as sha256sum).

Bytes.sub

Bytes.sub (start: int) (len: int) (b: Bytes) : Bytes

A slice of the bytes by start index and length. Works like Str.sub, including the error when the range is out of bounds.

Str.toUtf8 "abcd" |> Bytes.sub 1 2 |> Bytes.toHex

Bytes.toBase64

Bytes.toBase64 (b: Bytes) : string

Base64 of the bytes, on one line. Use it to turn bytes into text, since print, JSON and YAML do not accept raw bytes.

Bytes.toHex

Bytes.toHex (b: Bytes) : string

The bytes as lowercase hex on one line (the same format sha256 uses).

Str.toUtf8 "hi" |> Bytes.toHex

Bytes.tryFromBase64

Bytes.tryFromBase64 (s: string) : Option<Bytes>

Like fromBase64, but returns an Option: None for malformed base64.

Bytes.tryFromBase64 "!!!"

Color

Color.blue

Color.blue (s: string) : string

Color the text blue. No color when output is piped or NO_COLOR is set.

print (Color.blue "info")

Color.bold

Color.bold (s: string) : string

Make the text bold; combine with a color by nesting. No styling when output is piped or NO_COLOR is set.

print (Color.bold (Color.red "!"))

Color.cyan

Color.cyan (s: string) : string

Color the text cyan. No color when output is piped or NO_COLOR is set.

print (Color.cyan "x")

Color.dim

Color.dim (s: string) : string

Make the text dim. No styling when output is piped or NO_COLOR is set.

print (Color.dim "note")

Color.gray

Color.gray (s: string) : string

Color the text gray (bright black). No color when output is piped or NO_COLOR is set.

print (Color.gray "muted")

Color.green

Color.green (s: string) : string

Color the text green. No color when output is piped or NO_COLOR is set.

print (Color.green "ok")

Color.magenta

Color.magenta (s: string) : string

Color the text magenta. No color when output is piped or NO_COLOR is set.

print (Color.magenta "x")

Color.red

Color.red (s: string) : string

Color the text red. No color when output is piped or NO_COLOR is set.

print (Color.red "error")

Color.sgr

Color.sgr (code: string) (s: string) : string

Style the text with a raw terminal escape code, for 256-color (38;5;208) or truecolor (38;2;r;g;b). No styling when output is piped or NO_COLOR is set.

print (Color.sgr "38;5;208" "orange")

Color.underline

Color.underline (s: string) : string

Underline the text. No styling when output is piped or NO_COLOR is set.

print (Color.underline "link")

Color.yellow

Color.yellow (s: string) : string

Color the text yellow. No color when output is piped or NO_COLOR is set.

print (Color.yellow "warn")

Dir

Dir.copy

Dir.copy (src: string) (dst: string) : unit

Copy a directory and everything in it from src to dst. Raises if the destination exists (use Dir.deleteAll first to replace it) or lies inside the source. The copy is always recursive; symlinks are copied as links, never followed.

let d = Path.newTempDir ()
Dir.copy d $"{d}-c"
Dir.deleteAll d
Dir.deleteAll $"{d}-c"

Dir.create

Dir.create (path: string) : unit

Create a directory and any missing parents (like mkdir -p). Does nothing if it already exists, unlike copy and move, which raise on an existing destination.

Dir.create (Path.tempRoot ())

Dir.delete

Dir.delete (path: string) : unit

Delete an empty directory. Raises when it does not exist, or when it is not empty (the error points you to Dir.deleteAll).

Dir.delete (Path.newTempDir ())

Dir.deleteAll

Dir.deleteAll (path: string) : unit

Delete the directory and everything in it, recursively. There is no undo.

let d = Path.newTempDir ()
Dir.create $"{d}/tree-a/tree-b"
Dir.deleteAll d

Dir.exists

Dir.exists (path: string) : bool

True when the directory exists.

Dir.exists "."

Dir.list

Dir.list (path: string) : seq<string>

The full paths of the directory's entries, both files and directories, sorted by name and read immediately. Filter with Seq.where and File.exists/Dir.exists; for a recursive listing use Path.glob "**".

let d = Path.newTempDir ()
print $"{Dir.list d |> Seq.length}"
Dir.delete d

Dir.move

Dir.move (src: string) (dst: string) : unit

Move (rename) a directory from src to dst. Raises if the destination exists.

let d = Path.newTempDir ()
Dir.move d $"{d}-m"
Dir.delete $"{d}-m"

Dir.stat

Dir.stat (path: string) : seq<FileRow>

The directory's entries as FileRow values, the same rows ls shows, but for any directory (ls only lists the current one). Sorted by name and read immediately.

let d = Path.newTempDir ()
print $"{Dir.stat d |> Seq.length}"
Dir.delete d

Duration

Duration.average

Duration.average (xs: seq<Duration>) : Duration

The mean duration, truncated to whole milliseconds. Raises on an empty sequence.

[90s; 30s] |> Duration.average

Duration.h

Duration.h (n: int) : Duration

A duration of n hours.

Duration.h 2

Duration.m

Duration.m (n: int) : Duration

A duration of n minutes.

Duration.m 5

Duration.ms

Duration.ms (n: int) : Duration

A duration of n milliseconds. The function form of a literal like 500ms.

Duration.ms 500

Duration.parse

Duration.parse (text: string) : Duration

Parse a duration in the form show prints, such as 1h30m, 2.5s or 500ms. Raises on anything else, including values finer than a millisecond.

Duration.parse "1h30m"

Duration.s

Duration.s (n: int) : Duration

A duration of n seconds.

Duration.s 30

Duration.sleep

Duration.sleep (d: Duration) : unit

Pause for the duration. Zero returns immediately; a negative duration raises. The OS timer resolution (about 15ms on Windows, 1ms on Linux) means a short sleep may last longer than asked, never shorter. It is called Duration.sleep so that plain sleep still runs the system command.

Duration.sleep 10ms

Duration.sum

Duration.sum (xs: seq<Duration>) : Duration

Sum durations.

[90s; 30s] |> Duration.sum

Duration.toMillis

Duration.toMillis (d: Duration) : int

The total milliseconds as an int, for example for ratios or JSON fields.

Duration.toMillis 2m

Duration.toSeconds

Duration.toSeconds (d: Duration) : float

The total seconds as a float, without losing precision (2500ms is 2.5).

Duration.toSeconds 2500ms

Duration.tryParse

Duration.tryParse (text: string) : Option<Duration>

Like Duration.parse, but returns None instead of raising.

Duration.tryParse "not-a-duration"

Env

Env.fromFile

Env.fromFile (path: string) : seq<EnvVar>

Read .env lines (KEY=value) as EnvVar records.

Env.get

Env.get (name: string) : Option<string>

A process environment variable as an Option.

Env.get "PATH"

Env.ofPairs

Env.ofPairs (pairs: seq<string * string>) : seq<EnvVar>

Build EnvVar records from name/value tuples.

Env.ofPairs [("K", "V")] |> Seq.freeze

Env.pair

Env.pair (name: string) (value: string) : EnvVar

Build one EnvVar from a name and value.

Env.pair "K" "V"

Env.vars

Env.vars : seq<EnvVar>

Every environment variable as EnvVar records.

Env.vars |> Seq.freeze

File

File.append

File.append (path: string) (lines: seq<string>) : unit

Append a sequence of lines, safely even when other processes append to the same file. Each call is written as a single append (O_APPEND on Unix, append-only access on Windows), so its lines stay together and intact, and no lock blocks readers. If the system writes only part of the data (disk full, a size limit), the rest is retried and another writer's data may land in between. This holds on local filesystems, including bind mounts on the same host, but not on network filesystems (nfs, smb).

File.copy

File.copy (src: string) (dst: string) : unit

Copy a file from src to dst. The arguments are in the usual (src, dst) order rather than data-last, since neither one is the data. Raises, naming the path, if the destination exists; delete it first to overwrite.

let d = Path.newTempDir ()
["x"] |> File.write $"{d}/a.txt"
File.copy $"{d}/a.txt" $"{d}/b.txt"
Dir.deleteAll d

File.delete

File.delete (path: string) : unit

Delete a file. Raises, naming the path, when it does not exist. Copy and move never overwrite, so delete the destination first when you want to replace it.

let d = Path.newTempDir ()
["x"] |> File.write $"{d}/f.txt"
File.delete $"{d}/f.txt"
Dir.delete d

File.exists

File.exists (path: string) : bool

True when a path exists.

File.exists "README.md"

File.isExecutable

File.isExecutable (path: string) : bool

True when the owner execute bit is set (the bit an installer sets). Simpler than checking File.mode with Str.contains "x". Follows symlinks like the other File.* queries; a missing path raises. Windows has no execute bit, so there the answer is based on the extension (.exe/.bat/.cmd/.com).

File.isExecutable "."

File.mode

File.mode (path: string) : Option<string>

The path's permissions as text like rwxr-xr-x, wrapped in Some; None on Windows, which has no POSIX permissions. Like the other File.* queries it follows symlinks, so a broken link raises, naming the link. A typical use is checking for 0600 before File.readSecret: File.mode p == Some "rw-------".

File.mode "." |> Option.defaultValue "none"

File.move

File.move (src: string) (dst: string) : unit

Move (rename) a file from src to dst. Raises if the destination exists.

let d = Path.newTempDir ()
["x"] |> File.write $"{d}/a.txt"
File.move $"{d}/a.txt" $"{d}/b.txt"
Dir.deleteAll d

File.read

File.read (path: string) : seq<string>

Read a file's lines. The whole file is read immediately.

File.readBytes

File.readBytes (path: string) : Bytes

Read a file's raw bytes, exactly as stored, without decoding or splitting into lines (File.read does both and replaces invalid characters). The whole file is loaded into memory, so it is not meant for very large files.

File.readSecret

File.readSecret (path: string) : Secret

Read a file's whole content as a Secret, for example a secret mounted into a container. Trailing newlines are removed.

File.sha256

File.sha256 (path: string) : string

The SHA-256 digest of a file's bytes, as lowercase hex (same output as sha256sum). The file is streamed, never loaded into memory whole.

File.size

File.size (path: string) : Size

The file's size as a Size, which you can compare directly (File.size p > 10MiB); use Size.toBytes to get an int. Raises when the file does not exist (there is no trySize yet).

let d = Path.newTempDir ()
let f = $"{d}/a.txt"
["x"] |> File.write f
print $"{File.size f}"
Dir.deleteAll d

File.stat

File.stat (path: string) : FileRow

The FileRow for one path, the same row ls shows, so Path.glob ... |> Seq.map File.stat turns paths into rows. For a symlink it describes the link itself (kind Symlink, target Some), not what it points to. Raises when the path does not exist; note that a file found by glob may be deleted before stat reads it.

let d = Path.newTempDir ()
let f = $"{d}/a.txt"
["x"] |> File.write f
print (File.stat f).name
Dir.deleteAll d

File.write

File.write (path: string) (lines: seq<string>) : unit

Write a sequence of lines to a file, replacing its contents.

File.writeAtomic

File.writeAtomic (path: string) (lines: seq<string>) : unit

Write a sequence of lines by replacing the whole file at once: the content goes to a temp file in the same directory, is flushed to disk, and is then renamed over the target. Readers see either the complete old file or the complete new one, and a failed write leaves the original intact. For a symlink, the file it points to is replaced, not the link; the file's permissions and any existing BOM are kept. Because the file is a new inode, hard links are separated, open handles and tail -f keep seeing the old file, and a single-file bind mount into a container stops updating; use File.write when you need an in-place write. Inside a plan it records the same WriteFile op as File.write, and apply then does an in-place write.

File.writeBytes

File.writeBytes (path: string) (bytes: Bytes) : unit

Write raw bytes to a file exactly as given (File.write instead encodes lines and ends each with LF).

Float

Float.abs

Float.abs (f: float) : float

The absolute value.

Float.abs (0.0 - 1.5)

Float.average

Float.average (xs: seq<float>) : float

The mean of a float sequence. Raises on an empty sequence or a non-finite result.

[1.0; 2.0] |> Float.average

Float.near

Float.near (a: float) (b: float) (eps: float) : bool

True when a and b differ by at most eps. This is how to compare floats, since == does not accept them.

Float.near (0.1 + 0.2) 0.3 1e-9

Float.ofInt

Float.ofInt (n: int) : float

Convert an int to a float. weir never converts between them automatically.

Float.ofInt 3 / 2.0

Float.parse

Float.parse (text: string) : float

Parse a float in the form show prints. Raises on anything else, including NaN and Infinity, because weir floats are always finite.

Float.parse "1.5e-3"

Float.round

Float.round (f: float) : float

Round to the nearest whole number, with halves rounded away from zero (2.5 becomes 3.0).

Float.round 2.5

Float.sum

Float.sum (xs: seq<float>) : float

Add up a sequence of floats; raises if the sum is not finite. Seq.sum is for ints only; each numeric type has its own sum.

[1.5; 2.5] |> Float.sum

Float.toInt

Float.toInt (f: float) : int

The integer part, rounding toward zero. Raises when the value is outside the 64-bit int range.

Float.toInt 2.9

Float.tryParse

Float.tryParse (text: string) : Option<float>

Like Float.parse, but returns None instead of raising.

Float.tryParse "nope"

Frontier

Frontier.fold

Frontier.fold (keyOf: 'a -> string) (seed: 'b) (step: 'b -> 'a -> 'b * seq<'a>) (frontier: seq<'a>) : 'b

Fold over a graph that is discovered as you go. Nodes are taken from a first-in, first-out queue; a node whose key was already seen is skipped. The step returns the new accumulator and the node's children, which are added to the queue. It ends when the queue is empty. Cycles are safe, and after 100000 steps it raises instead of running forever. A node with key "" is never skipped as a duplicate.

[1] |> Frontier.fold (fun n -> show n) 0 (fun acc n -> (acc + n, if n < 3 then [n + 1] else []))

Graph

Graph.reach

Graph.reach (keyOf: 'a -> string) (neighbors: 'a -> seq<'a>) (start: 'a) : seq<'a>

Every node reachable from a start node, breadth-first, each listed once; nodes with the same key count as one, so cycles are safe. The neighbors function defines the graph, so nothing has to be built up front.

Graph.reach (fun n -> show n) (fun n -> if n < 3 then [n + 1] else []) 1

Http

Http.defaults

Http.defaults : HttpRequest

The default request: method = Get, empty url, NoAuth, no body, 30s timeout. Start from it with Http.send { Http.defaults with url = u }. Http.get url and the other method helpers return a request built from it.

Http.delete

Http.delete (url: string) : HttpRequest

Build a Delete request for the url.

Http.expect

Http.expect (request: HttpRequest) : seq<string>

Send a request and return the response body as lines. Raises on a non-2xx status; the error names the method, url and status and includes the start of the body. Use Http.get url |> Http.expect for a simple read; requests built with with work the same way. If you need to inspect an error response, use Http.send instead.

Http.get

Http.get (url: string) : HttpRequest

Build a GET request for the url; nothing is sent yet. Http.get u is the same as { Http.defaults with method = Get; url = u }. Send it with Http.send (returns the response, whatever the status) or Http.expect (returns the body, raises on failure). Set other fields with with: Http.send { Http.get u with auth = Bearer t }.

Http.get "http://x/y"

Http.head

Http.head (url: string) : HttpRequest

Build a Head request for the url.

Http.header

Http.header (name: string) (resp: HttpResponse) : Option<string>

The first value of a response header, or None when it is missing. Header names match case-insensitively, as HTTP requires, so ETag and etag are the same header. For headers that can repeat (Set-Cookie), use Http.headerAll.

(HttpResponse { status = 200; headers = [("ETag", "abc")]; body = [""] }) |> Http.header "etag"

Http.headerAll

Http.headerAll (name: string) (resp: HttpResponse) : seq<string>

Every value of a response header, in the order received, matching the name case-insensitively. Use it for headers that can repeat (Set-Cookie). Empty when the header is missing.

Http.options

Http.options (url: string) : HttpRequest

Build an Options request for the url.

Http.patch

Http.patch (url: string) : HttpRequest

Build a Patch request for the url.

Http.post

Http.post (url: string) : HttpRequest

Build a Post request for the url; add a body or auth with with.

Http.put

Http.put (url: string) : HttpRequest

Build a Put request for the url.

Http.query

Http.query (url: string) : HttpRequest

Build a QUERY request (RFC 10008) for the url. QUERY is idempotent by definition, so wrapping it in retry is safe. Few servers support it yet, so expect 405.

Http.send

Http.send (request: HttpRequest) : HttpResponse

Send a request and return the response. A non-2xx status such as 404 is returned, not raised; only network failures raise. A Json body sends to json output exactly as given. Auth values hold Secrets, and show masks them.

Http.withQuery

Http.withQuery (params: seq<string * string>) (base: string) : string

Add a query string to a url. The parameters come first and the url last, so it fits a pipe: url |> Http.withQuery [(k, v)]. Keys and values are percent-encoded, so a space or & cannot break the url. (Not to be confused with Http.query, which builds a QUERY request.)

"http://x/s" |> Http.withQuery [("q", "a b")]

Instant

Instant.epochMs

Instant.epochMs (t: Instant) : int

Milliseconds since the Unix epoch, as an int. Useful for JSON fields and other tools (like date +%s%3N).

Instant.parse "1970-01-01T00:00:01Z" |> Instant.epochMs

Instant.now

Instant.now () : Instant

The current instant (UTC).

Instant.now () > Instant.parse "2020-01-01"

Instant.ofEpochMs

Instant.ofEpochMs (ms: int) : Instant

The instant for a number of milliseconds since the Unix epoch.

Instant.ofEpochMs 0 |> show

Instant.parse

Instant.parse (text: string) : Instant

Parse an ISO 8601 timestamp with Z or a numeric offset, converted to UTC. A bare date means midnight UTC. Raises on anything else; use tryParse to get an Option instead.

Instant.parse "2026-08-14T12:00:00Z"

Instant.parseWith

Instant.parseWith (format: string) (text: string) : Instant

Parse a timestamp using a format string: %Y %m %d %e (1-2 digit day) %b (Jan..Dec) %H %M %S %f %z, with %% for a literal %; any other text must match exactly. Only the start of the text has to match, so trailing text such as the rest of a log line is ignored. Without %z the time is UTC. Raises on a mismatch, naming the position.

Instant.parseWith "%Y/%m/%d %H:%M:%S" "2026/08/14 09:15:00 GET /health"

Instant.tryParse

Instant.tryParse (text: string) : Option<Instant>

Some instant, or None when the text is not ISO 8601.

Instant.tryParse "not-a-time"

Instant.tryParseWith

Instant.tryParseWith (format: string) (text: string) : Option<Instant>

Some instant, or None when the text does not match. An unknown directive in the format still raises, because that is a bug in the format, not in the data.

Instant.tryParseWith "%Y-%m-%d" "no timestamp here"

Json

Json.inferShape

Json.inferShape (lines: seq<string>) : string

Generate draft type declarations from a JSON sample and return them as text; this is what #infer uses. The top record is named Root; nested records are named after their field, with array element types in the singular. Array elements are merged, and a key missing from some elements becomes an Option. An object used as a dictionary (values of one shape with mostly non-identifier keys, different keys in sibling objects, or an empty {}) becomes seq<string * _>. Notes are added as // lines. The draft only covers what the sample contains, so review and edit it. This runs on a sample at runtime; it does not affect type checking.

print (Json.inferShape ["{\"id\": 1, \"name\": \"x\"}"])

Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from json behave.

Log

Log.debug

Log.debug (message: string) : unit

Write a DEBUG line to stderr; hidden unless WEIR_LOG=debug (or trace). All Log functions write to stderr so stdout stays free for data; there is no option to log to stdout.

Log.debug "cache miss"

Log.debugWith

Log.debugWith (thunk: unit -> string) : unit

Like Log.debug, but takes a function that builds the message, called only when debug logging is on.

Log.debugWith (fun () -> "expensive detail")

Log.info

Log.info (message: string) : unit

Write an INFO line to stderr. Shown by default; set WEIR_LOG to a level (case-insensitive) to change what is shown, or to off to silence logging.

Log.info "deploy starting"

Log.infoWith

Log.infoWith (thunk: unit -> string) : unit

Like Log.info, but takes a function that builds the message, called only when info logging is on.

Log.infoWith (fun () -> "expensive detail")

Log.trace

Log.trace (message: string) : unit

Write a TRACE line to stderr. This is the most detailed level, hidden unless WEIR_LOG=trace.

Log.trace "entering the retry loop"

Log.traceWith

Log.traceWith (thunk: unit -> string) : unit

Like Log.trace, but takes a function that builds the message, called only when trace logging is on. Plain Log.trace always builds its message, so use this when that is expensive.

Log.traceWith (fun () -> "expensive detail")

Log.warn

Log.warn (message: string) : unit

Write a WARN line to stderr. This is the highest level, shown unless WEIR_LOG=off.

Log.warn "lockfile missing, regenerating"

Log.warnWith

Log.warnWith (thunk: unit -> string) : unit

Like Log.warn, but takes a function that builds the message, called only when warnings are shown.

Log.warnWith (fun () -> "expensive detail")

Map

Map.add

Map.add (key: string) (value: 'a) (m: Map<string, 'a>) : Map<string, 'a>

A new map with the entry added, replacing any existing value for the key; the original is unchanged.

Map.ofPairs [("a", 1)] |> Map.add "k" 5

Map.count

Map.count (m: Map<string, 'a>) : int

The number of entries.

Map.ofPairs [("aaa", 1)] |> Map.count

Map.get

Map.get (key: string) (m: Map<string, 'a>) : 'a

The value for a key. Raises, naming the key, when it is missing; use tryGet to get an Option instead.

Map.ofPairs [("aaa", 1)] |> Map.get "aaa"

Map.has

Map.has (key: string) (m: Map<string, 'a>) : bool

True when a key is present.

Map.ofPairs [("aaa", 1)] |> Map.has "aaa"

Map.keys

Map.keys (m: Map<string, 'a>) : seq<string>

The keys, sorted.

Map.ofPairs [("b", 2); ("a", 1)] |> Map.keys |> Seq.freeze

Map.ofPairs

Map.ofPairs (pairs: seq<string * 'a>) : Map<string, 'a>

Build a map from (key, value) pairs. If a key repeats, the last value wins, as when reading JSON.

Map.ofPairs [("a", 1); ("b", 2)]

Map.pairs

Map.pairs (m: Map<string, 'a>) : seq<string * 'a>

The entries as (key, value) pairs, key-sorted.

Map.ofPairs [("b", 2); ("a", 1)] |> Map.pairs |> Seq.freeze

Map.remove

Map.remove (key: string) (m: Map<string, 'a>) : Map<string, 'a>

A new map without the key (a missing key is not an error); the original is unchanged.

Map.ofPairs [("a", 1); ("k", 2)] |> Map.remove "k"

Map.tryGet

Map.tryGet (key: string) (m: Map<string, 'a>) : Option<'a>

Some value when the key is present, None when not.

Map.ofPairs [("aaa", 1)] |> Map.tryGet "zzz"

Map.values

Map.values (m: Map<string, 'a>) : seq<'a>

The values, in key-sorted order.

Map.ofPairs [("b", 2); ("a", 1)] |> Map.values |> Seq.freeze

Net

Net.portOpen

Net.portOpen (port: int) : bool

True when 127.0.0.1:<port> accepts a TCP connection within 250ms. Meant as a readiness check inside poll. Only local ports are supported for now.

Net.portOpen 1

Option

Option.bind

Option.bind (f: 'a -> Option<'b>) (opt: Option<'a>) : Option<'b>

Apply a function that itself returns an Option to the value inside Some, without nesting the result. Use it to chain lookups that may each fail.

Some 5 |> Option.bind (fun x -> Some (x + 1))

Option.defaultValue

Option.defaultValue (fallback: 'a) (opt: Option<'a>) : 'a

The Some value, or a fallback when None.

Option.defaultValue 0 (Some 5)

Option.defaultWith

Option.defaultWith (fallback: unit -> 'a) (opt: Option<'a>) : 'a

Like defaultValue, but the fallback is computed only when None.

Option.defaultWith (fun () -> 0) None

Option.flatten

Option.flatten (opt: Option<Option<'a>>) : Option<'a>

Collapse a nested Option<Option<T>> into Option<T> (Some (Some x) -> Some x; any None -> None).

Option.flatten (Some (Some 5))

Option.iter

Option.iter (f: 'a -> unit) (opt: Option<'a>) : unit

Run a side effect on the value inside Some; do nothing for None. Saves writing a match.

Some "x" |> Option.iter print

Option.map

Option.map (f: 'a -> 'b) (opt: Option<'a>) : Option<'b>

Apply a function to the value inside Some; None stays None.

Option.map (fun x -> x + 1) (Some 5)

Option.orElse

Option.orElse (fallback: Option<'a>) (opt: Option<'a>) : Option<'a>

The option itself when it is Some, otherwise the fallback option. The fallback comes first so it works in a pipe. The result is still an Option; use Option.defaultValue to unwrap. The fallback is evaluated even when the option is Some.

None |> Option.orElse (Some 1)

Option.orElseWith

Option.orElseWith (f: unit -> Option<'a>) (opt: Option<'a>) : Option<'a>

Like orElse, but the fallback is a function (unit -> Option) called only when the option is None. Use it when the fallback is expensive to compute.

None |> Option.orElseWith (fun () -> Some 1)

Path

Path.cacheHome

Path.cacheHome () : string

The base directory for user cache: $XDG_CACHE_HOME (or ~/.cache) on POSIX, %LOCALAPPDATA% on Windows.

Path.cacheHome ()

Path.combine

Path.combine (a: string) (b: string) : string

Join two path segments.

Path.combine "a" "b"

Path.configHome

Path.configHome () : string

The base directory for user config: $XDG_CONFIG_HOME (or ~/.config) on POSIX, %APPDATA% on Windows.

Path.configHome ()

Path.dir

Path.dir (path: string) : string

The directory part of a path.

Path.dir "a/b/c"

Path.extension

Path.extension (path: string) : string

The extension, including the dot.

Path.extension "a.txt"

Path.fileName

Path.fileName (path: string) : string

The final component of a path.

Path.fileName "a/b.txt"

Path.glob

Path.glob (pattern: string) : seq<string>

The paths matching a glob pattern, lazily. ** does not follow symlinks.

Path.glob "*.nope123" |> Seq.freeze

Path.home

Path.home () : string

The user's home directory: $HOME as set (even if that directory is missing), the OS lookup when it is unset; raises when there is none (an empty $HOME). Has no side effects. In a command line, a typed ~ means this path; in an expression, build paths with $"{Path.home ()}/.bashrc".

Path.home ()

Path.newTempDir

Path.newTempDir () : string

Create a new, uniquely named directory under the temp root and return its path (named the same way as within tmp). It is never removed automatically; deleting it is up to you or the OS. For a directory that is removed when a block exits (also on Ctrl+C or kill), use within tmp dir.

Path.newTempDir () |> Str.startsWith (Path.tempRoot ())

Path.normalize

Path.normalize (path: string) : string

Resolve '.' and '..' segments in the text of a path, without touching the filesystem, using the current directory, or following symlinks. combine leaves '..' in place and under rejects paths that leave the base; use normalize when going up out of a directory is intended. A relative path keeps its leading '..' segments; at the root of an absolute path, '..' is dropped.

Path.normalize "src/App/../Core/Core.csproj"

Path.stateHome

Path.stateHome () : string

The base directory for user state: $XDG_STATE_HOME (or ~/.local/state) on POSIX, %LOCALAPPDATA% on Windows. Where the REPL keeps its history.

Path.stateHome ()

Path.stem

Path.stem (path: string) : string

The file name without its extension.

Path.stem "a/b.txt"

Path.tempRoot

Path.tempRoot () : string

The system temp directory, in the platform's native form and without a trailing separator. Has no side effects.

Path.tempRoot ()

Path.under

Path.under (base: string) (name: string) : string

Join a name onto base, normalize, and raise if the result ends up outside base. The check is by whole path segments, so uploads-evil is not inside uploads. Use combine for paths you control and under for paths you do not (such as user input). It works on the text only and never touches the disk or follows symlinks. An absolute or Windows-style name is rejected on every platform.

Path.under (Path.tempRoot ()) "a/b"

Plan

Plan.apply

Plan.apply (plan: Plan) : unit

Carry out a plan's operations in the order they were recorded, using the normal functions. Stops at the first operation that fails; earlier ones stay done, since there is no rollback. Not allowed inside a plan block.

Plan.isEmpty

Plan.isEmpty (plan: Plan) : bool

True when the plan recorded no operations, for example because the script only read things.

Plan.ops

Plan.ops (plan: Plan) : seq<Op>

The operations recorded in a plan, in the order they were recorded, as a seq<Op>. Useful for tests and inspection (plan <block> == [WriteFile(p, c)]); Op values can be compared and shown, with Secrets masked.

Plan.preview

Plan.preview (plan: Plan) : seq<string>

A readable description of a plan's operations, one line each. Nothing has been changed yet; only Plan.apply makes the changes. Secrets are masked.

Poll

Poll.defaults

Poll.defaults : Poll

The default poll options: timeout = 1m, interval = 1s.

Proc

Proc.pid

Proc.pid (p: Proc) : int

The child's OS process id.

within proc p = <command> binds the handle; see #help within

Proc.running

Proc.running (p: Proc) : bool

True while the child has not exited.

poll watch=p checks this for you and gives better errors.

Proc.stop

Proc.stop (p: Proc) : unit

Kill the process and its children now and wait for them. Safe to call more than once; the block's exit then has nothing left to do.

Proc.tail

Proc.tail (p: Proc) : seq<string>

The child's last ~100 lines of output (stderr first). You can read it while the process is still running.

Errors from poll watch=… include this automatically.

Proc.wait

Proc.wait (p: Proc) : int

Wait until the child exits on its own and return its exit code. Use this to let a scoped process finish; otherwise it is killed when the block exits.

Result

Result.defaultValue

Result.defaultValue (fallback: 'a) (r: Result<'a, 'e>) : 'a

The value inside Ok, or the fallback for an Error.

File.read "n.txt" |> Seq.length |> try result |> Result.defaultValue 0

Result.isOk

Result.isOk (r: Result<'a, 'e>) : bool

True for Ok, false for an Error.

File.read "n.txt" |> try result |> Result.isOk

Result.map

Result.map (f: 'a -> 'b) (r: Result<'a, 'e>) : Result<'b, 'e>

Apply a function to the value inside Ok; an Error stays as it is.

File.read "n.txt" |> Seq.length |> try result |> Result.map (fun n -> n * 2)

Result.toOption

Result.toOption (r: Result<'a, 'e>) : Option<'a>

Some value for Ok, None for an Error (the message is dropped).

File.read "n.txt" |> try result |> Result.toOption

Retry

Retry.defaults

Retry.defaults : Retry

The default retry options: attempts = 5, delay = 1s, timeout = None. retry attempts=5 is short for retry { Retry.defaults with attempts = 5 }.

Secret

Secret.map

Secret.map (f: string -> string) (s: Secret) : Secret

Transform a secret's value while keeping it secret. Secret.map (fun t -> "Bearer " + t) gives a Secret, whereas revealing and then concatenating would give a plain string.

Secret.map (fun t -> "Bearer " + t) (Secret.of "x")

Secret.of

Secret.of (s: string) : Secret

Mark a computed string (a generated token, a derived key) as secret. show prints it as ***; only Secret.reveal gives back the plain value.

Secret.of "hunter2"

Secret.reveal

Secret.reveal (s: Secret) : string

The secret's plain value. This is the only way to get it out of a Secret, so each use (a header, a hash) is explicit and easy to find when auditing.

Secret.reveal (Secret.of "x")

Self

Self.args

Self.args : seq<string>

The command-line arguments of the script that was run. The same in every module.

Self.entryPath

Self.entryPath : string

The path of the script that was run. Unlike scriptPath, it is the same in every module.

Self.pid

Self.pid : int

This process's id.

Self.prompt

Self.prompt (message: string) : string

Write a message to stderr and read one line from stdin, for interactive input. The prompt goes to stderr, so piped stdout still carries only data. Raises at end of input instead of returning an empty answer. To read the rest of stdin as a stream, use Self.stdin — both read the same input, so a line a prompt took is not seen again.

let name = Self.prompt "your name?". In tests, pipe the answer in.

Self.scriptPath

Self.scriptPath : string

The path of the file that contains this code; in a module, the module's own path.

Self.stdin

Self.stdin : seq<string>

This process's standard input, as lazy lines. The same in every module.

Seq

Seq.append

Seq.append (xs: seq<'a>) (ys: seq<'a>) : seq<'a>

Concatenate two sequences, lazily.

Seq.append [1; 2] [3; 4] |> Seq.freeze

Seq.average

Seq.average (xs: seq<int>) : float

The mean of an int sequence, as a float. Raises on an empty sequence. For floats, sizes and durations use Float.average, Size.average or Duration.average.

[1; 2] |> Seq.average

Seq.choose

Seq.choose (f: 'a -> Option<'b>) (xs: seq<'a>) : seq<'b>

Apply a function that returns an Option and keep only the Some values, lazily.

[1; 2; 3] |> Seq.choose (fun x -> if x > 1 then Some x else None) |> Seq.freeze

Seq.chunkBySize

Seq.chunkBySize (n: int) (xs: seq<'a>) : seq<seq<'a>>

Split into consecutive batches of at most n elements, lazily. The last batch may be shorter.

[1; 2; 3; 4; 5] |> Seq.chunkBySize 2 |> Seq.map Seq.freeze |> Seq.freeze

Seq.collect

Seq.collect (f: 'a -> seq<'b>) (xs: seq<'a>) : seq<'b>

Map each element to a sequence and flatten the results into one, lazily (flatMap in other languages).

["a<b"; "c"] |> Seq.collect (Str.split "<") |> Seq.freeze

Seq.concat

Seq.concat (xss: seq<seq<'a>>) : seq<'a>

Flatten a sequence of sequences into one, lazily.

[[1; 2]; [3]] |> Seq.concat |> Seq.freeze

Seq.contains

Seq.contains (x: 'a) (xs: seq<'a>) : bool

True when an element is present.

Seq.contains 2 [1; 2; 3]

Seq.countBy

Seq.countBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'b * int>

Count the elements per key as (key, count) pairs, in the order each key is first seen. The whole input is read when the first pair is requested.

["a"; "bb"; "c"] |> Seq.countBy Str.length |> Seq.freeze

Seq.distinct

Seq.distinct (xs: seq<'a>) : seq<'a>

Drop duplicate elements, keeping first order.

[1; 1; 2] |> Seq.distinct |> Seq.freeze

Seq.distinctBy

Seq.distinctBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>

Keep only the first element for each key, lazily. Like distinct, but compares by a key function.

["a"; "bb"; "cc"] |> Seq.distinctBy Str.length |> Seq.freeze

Seq.equal

Seq.equal (xs: seq<'a>) (ys: seq<'a>) : bool

True when both sequences have the same length and equal elements in order. Elements must support equality, as for Seq.contains. It stops at the first mismatch without reading further. Prefer it to joining and comparing strings, which can give a wrong answer when elements contain the separator.

["a"; "b"] |> Seq.equal ["a"; "b"]

Seq.exactlyOne

Seq.exactlyOne (xs: seq<'a>) : 'a

The only element. Raises if there are none or more than one (unlike head, which quietly ignores extra elements). Use it for command output that should be exactly one line.

["one line"] |> Seq.exactlyOne |> print

Seq.except

Seq.except (excluded: seq<'a>) (xs: seq<'a>) : seq<'a>

The elements of the sequence that are not in the excluded values. The excluded values come first and are read in full on the first request; the sequence itself is read lazily.

[1; 2; 3; 4] |> Seq.except [2; 4] |> Seq.freeze

Seq.exists

Seq.exists (pred: 'a -> bool) (xs: seq<'a>) : bool

True when any element satisfies a predicate.

[1; 2; 3] |> Seq.exists (fun x -> x > 2)

Seq.find

Seq.find (pred: 'a -> bool) (xs: seq<'a>) : 'a

The first element a predicate accepts. Raises when none match; use tryFind to get an Option instead.

[1; 5; 3] |> Seq.find (fun x -> x > 2)

Seq.fold

Seq.fold (f: 'a -> 'b -> 'a) (init: 'a) (xs: seq<'b>) : 'a

Combine the elements from left to right, starting from an initial accumulator.

[1; 2; 3] |> Seq.fold (+) 0

Seq.forall

Seq.forall (pred: 'a -> bool) (xs: seq<'a>) : bool

True when every element satisfies a predicate.

[1; 2; 3] |> Seq.forall (fun x -> x > 0)

Seq.freeze

Seq.freeze (xs: seq<'a>) : seq<'a>

Evaluate a lazy sequence now and keep the results.

[1; 2; 3] |> Seq.map (fun x -> x + 1) |> Seq.freeze

Freeze a sequence you will read more than once, or to capture its contents now (such as a command's output or a directory listing) before something changes them.

Seq.groupBy

Seq.groupBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'b * seq<'a>>

Group elements by a key into (key, items) pairs, as in F#.

[1; 2; 3] |> Seq.groupBy (fun x -> x) |> Seq.freeze

Seq.head

Seq.head (xs: seq<'a>) : 'a

The first element (raises on empty).

Seq.head [1; 2; 3]

Seq.indexed

Seq.indexed (xs: seq<'a>) : seq<int * 'a>

Pair every element with its zero-based index, lazily. For F#'s mapi/iteri, use indexed |> map/iter on the pairs.

["a"; "b"] |> Seq.indexed |> Seq.freeze

Seq.isEmpty

Seq.isEmpty (xs: seq<'a>) : bool

True when the sequence has no elements.

Seq.isEmpty [1; 2; 3]

Seq.item

Seq.item (i: int) (xs: seq<'a>) : 'a

The element at a zero-based index (raises out of range).

[1; 2; 3] |> Seq.item 0

Seq.iter

Seq.iter (f: 'a -> unit) (xs: seq<'a>) : unit

Run a unit-returning effect over each element.

[1; 2; 3] |> Seq.iter (fun x -> ())

Seq.last

Seq.last (xs: seq<'a>) : 'a

The last element. Raises 'last: empty sequence' when empty; Seq.tryLast returns None instead. It reads the whole sequence, so it never returns on an infinite one.

[1; 2; 3] |> Seq.last

Seq.length

Seq.length (xs: seq<'a>) : int

Count the elements (forces the sequence).

Seq.length [1; 2; 3]

Seq.map

Seq.map (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>

Apply a function to every element, lazily.

[1; 2; 3] |> Seq.map (fun x -> x + 1) |> Seq.freeze

Seq.max

Seq.max (xs: seq<'a>) : 'a

The largest element, found in one pass without sorting. Elements must be comparable; raises on an empty sequence.

Seq.max [3; 1; 2]

Seq.maxBy

Seq.maxBy (key: 'a -> 'b) (xs: seq<'a>) : 'a

The element with the largest key. Keys must be comparable; raises on an empty sequence.

["a"; "ccc"] |> Seq.maxBy Str.length

Seq.min

Seq.min (xs: seq<'a>) : 'a

The smallest element, found in one pass without sorting. Elements must be comparable; raises on an empty sequence.

Seq.min [3; 1; 2]

Seq.minBy

Seq.minBy (key: 'a -> 'b) (xs: seq<'a>) : 'a

The element with the smallest key. Keys must be comparable; raises on an empty sequence.

["a"; "ccc"] |> Seq.minBy Str.length

Seq.pairwise

Seq.pairwise (xs: seq<'a>) : seq<'a * 'a>

Adjacent pairs: (e0,e1), (e1,e2), and so on.

[1; 2; 3] |> Seq.pairwise |> Seq.freeze

Seq.pfirst

Seq.pfirst (f: 'a -> 'b) (xs: seq<'a>) : 'b

Run the function on every element in parallel and return the first successful result. Processes started by the others are killed, along with their children. If every call fails, the error of the earliest element is raised; an empty sequence raises. Errors from the calls that did not win are discarded, so if only one element can ever succeed, the others' errors stay hidden and a misconfiguration can go unnoticed.

[3; 1; 2] |> Seq.pfirst (fun n -> n * 10)

A race, not a retry: for example the same download from several mirrors, where the first answer wins and the losers' processes are killed.

Seq.pfirstWith

Seq.pfirstWith (degree: int) (f: 'a -> 'b) (xs: seq<'a>) : 'b

Seq.pfirst with a maximum number of parallel calls.

[1; 2] |> Seq.pfirstWith 2 (fun n -> n)

Seq.pick

Seq.pick (f: 'a -> Option<'b>) (xs: seq<'a>) : 'b

Apply a function to each element and return the value of the first Some result. Raises when there is none; use tryPick to get an Option instead.

["a"; "12"] |> Seq.pick Str.tryToInt

Seq.piter

Seq.piter (f: 'a -> unit) (xs: seq<'a>) : unit

Run an effect over each element in parallel.

[1; 2; 3] |> Seq.piter (fun x -> ())

Each worker gets its own copy of the session: a cd inside a worker affects only that worker and is gone when the workers finish.

Seq.piterWith

Seq.piterWith (n: int) (f: 'a -> unit) (xs: seq<'a>) : unit

Seq.piter with an explicit worker count.

[1; 2; 3] |> Seq.piterWith 2 (fun x -> ())

Plain piter uses fewer workers when parallel calls are nested; the n you give here is always used as is.

Seq.pmap

Seq.pmap (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>

Map in parallel across worker threads.

[1; 2; 3] |> Seq.pmap (fun x -> x + 1) |> Seq.freeze

Results keep the input order. It runs eagerly with up to 64 workers; if several items fail, the error from the earliest item is the one reported.

Seq.pmapWith

Seq.pmapWith (n: int) (f: 'a -> 'b) (xs: seq<'a>) : seq<'b>

Seq.pmap with an explicit number of workers. Use it when the work is rate-limited or uses a lot of memory.

[1; 2; 3] |> Seq.pmapWith 2 (fun x -> x + 1) |> Seq.freeze

Plain pmap uses fewer workers when parallel calls are nested; the n you give here is always used as is.

Seq.range

Seq.range (start: int) (step: int) (stop: int) : seq<int>

A lazy arithmetic range: start, step, stop.

Seq.range 1 1 5 |> Seq.freeze

Seq.reduce

Seq.reduce (f: 'a -> 'a -> 'a) (xs: seq<'a>) : 'a

Like fold without an initial value: the first element is the starting accumulator. Raises on an empty sequence; use fold when you have a starting value.

[1; 2; 3] |> Seq.reduce (+)

Seq.replicate

Seq.replicate (n: int) (x: 'a) : seq<'a>

A sequence of n copies of a value, lazily. Raises on a negative count.

Seq.replicate 3 "x" |> Seq.freeze

Seq.rev

Seq.rev (xs: seq<'a>) : seq<'a>

Reverse the sequence. The whole input is read when the first element is requested, so it cannot be used on an infinite sequence.

[1; 2; 3] |> Seq.rev |> Seq.freeze

Seq.scan

Seq.scan (f: 'a -> 'b -> 'a) (init: 'a) (xs: seq<'b>) : seq<'a>

Like fold, but returns every intermediate accumulator, starting with the initial value, lazily.

[1; 2; 3] |> Seq.scan (+) 0 |> Seq.freeze

Seq.skip

Seq.skip (n: int) (xs: seq<'a>) : seq<'a>

Drop the first n elements, keep the rest lazily.

[1; 2; 3] |> Seq.skip 1 |> Seq.freeze

Seq.skipWhile

Seq.skipWhile (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>

Drop leading elements while the predicate holds, then return all the rest, lazily.

[1; 2; 9; 1] |> Seq.skipWhile (fun x -> x < 5) |> Seq.freeze

Seq.sort

Seq.sort (xs: seq<'a>) : seq<'a>

Sort the elements in ascending order (they must be comparable). The whole input is read when the first element is requested.

["pear"; "apple"] |> Seq.sort |> Seq.freeze

Seq.sortBy

Seq.sortBy (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>

Order by a key projection.

[3; 1; 2] |> Seq.sortBy (fun x -> x) |> Seq.freeze

Seq.sortByDescending

Seq.sortByDescending (key: 'a -> 'b) (xs: seq<'a>) : seq<'a>

Order by a key projection, descending.

[1; 3; 2] |> Seq.sortByDescending (fun x -> x) |> Seq.freeze

Seq.sortDescending

Seq.sortDescending (xs: seq<'a>) : seq<'a>

Sort the elements in descending order (they must be comparable). The whole input is read when the first element is requested.

[1; 3; 2] |> Seq.sortDescending |> Seq.freeze

Seq.sum

Seq.sum (xs: seq<int>) : int

Add the elements of an int sequence.

Seq.sum [1; 2; 3]

Seq.take

Seq.take (n: int) (xs: seq<'a>) : seq<'a>

The first n elements, lazily; pairs with Seq.skip.

[1; 2; 3] |> Seq.take 2 |> Seq.freeze

Seq.takeWhile

Seq.takeWhile (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>

The leading elements for which the predicate holds, lazily; stops at the first one that fails.

[1; 2; 9; 1] |> Seq.takeWhile (fun x -> x < 5) |> Seq.freeze

Seq.tryExactlyOne

Seq.tryExactlyOne (xs: seq<'a>) : Option<'a>

Like exactlyOne, but returns an Option: Some when there is exactly one element, None otherwise.

[42] |> Seq.tryExactlyOne

Seq.tryFind

Seq.tryFind (pred: 'a -> bool) (xs: seq<'a>) : Option<'a>

The first element a predicate accepts, as an Option.

[1; 2; 3] |> Seq.tryFind (fun x -> x > 1)

Seq.tryHead

Seq.tryHead (xs: seq<'a>) : Option<'a>

The first element as an Option, None when empty.

Seq.tryHead [1; 2; 3]

Seq.tryItem

Seq.tryItem (i: int) (xs: seq<'a>) : Option<'a>

The element at an index as an Option.

[1; 2; 3] |> Seq.tryItem 0

Seq.tryLast

Seq.tryLast (xs: seq<'a>) : Option<'a>

The last element as an Option, None when empty. Reads the whole sequence.

[] |> Seq.tryLast

Seq.tryPick

Seq.tryPick (f: 'a -> Option<'b>) (xs: seq<'a>) : Option<'b>

Apply a function to each element and return the first Some result, or None.

["a"; "12"] |> Seq.tryPick Str.tryToInt

Seq.tryReduce

Seq.tryReduce (f: 'a -> 'a -> 'a) (xs: seq<'a>) : Option<'a>

Like reduce, but returns an Option: Some result, or None for an empty sequence (where reduce raises).

[1; 2] |> Seq.where (fun n -> n > 9) |> Seq.tryReduce (+)

Seq.where

Seq.where (pred: 'a -> bool) (xs: seq<'a>) : seq<'a>

Keep the elements a predicate accepts, lazily.

[1; 2; 3] |> Seq.where (fun x -> x > 1) |> Seq.freeze

Seq.windowed

Seq.windowed (n: int) (xs: seq<'a>) : seq<seq<'a>>

Sliding windows of n consecutive elements, produced lazily. A source shorter than n gives an empty sequence (there are no partial windows). The windows share the same cached elements. Raises when n <= 0.

[1; 2; 3] |> Seq.windowed 2 |> Seq.map Seq.freeze |> Seq.freeze

Seq.zip

Seq.zip (xs: seq<'a>) (ys: seq<'b>) : seq<'a * 'b>

Pair two sequences element-wise, stopping at the shorter.

Seq.zip [1; 2] [3; 4] |> Seq.freeze

Server

Server.port

Server.port (srv: Server) : int

The port the listener bound.

within serve srv = { port = 8080; maxConcurrent = 4 } handler binds the handle; see #help within

Server.running

Server.running (srv: Server) : bool

True while the listener is still open (the scope has not exited).

Server.streamErrors

Server.streamErrors (srv: Server) : seq<string>

Error messages from Stream response bodies that raised partway through; the client received a truncated body for each. Empty until one fails. Streaming errors are reported here instead of being raised out of the handler.

When a Stream body raises, the client's response is cut off and the error is recorded here.

Size

Size.average

Size.average (xs: seq<Size>) : Size

The mean size, truncated to whole bytes. Raises on an empty sequence.

[1KiB; 3KiB] |> Size.average

Size.bytes

Size.bytes (n: int) : Size

A size of n bytes. The function form of a literal like 512B.

Size.bytes 512

Size.parse

Size.parse (text: string) : Size

Parse a size. Binary units are powers of 1024 (1.5MiB); SI units are powers of 10 (1MB is 10^6 bytes); B means bytes. Raises if the value is not a whole number of bytes.

Size.parse "1.5MiB"

Size.sum

Size.sum (xs: seq<Size>) : Size

Add up sizes; the total is a Size.

[1KiB; 512B] |> Size.sum

Size.toBytes

Size.toBytes (s: Size) : int

The exact number of bytes as an int. (show truncates sizes to one decimal place.)

Size.toBytes 2KiB

Size.tryParse

Size.tryParse (text: string) : Option<Size>

Like Size.parse, but returns None instead of raising.

Size.tryParse "nope"

Str

Str.contains

Str.contains (needle: string) (s: string) : bool

True when a substring is present.

"abc" |> Str.contains "b"

Str.endsWith

Str.endsWith (suffix: string) (s: string) : bool

True when the string ends with a suffix.

"abc" |> Str.endsWith "c"

Str.fields

Str.fields (s: string) : seq<string>

Split on runs of whitespace (the same whitespace trim removes) into fields. Never produces empty pieces; a blank or empty string gives an empty sequence.

"NAME   READY  1/1" |> Str.fields |> Seq.freeze

Str.fromBase64

Str.fromBase64 (s: string) : string

Decode standard base64 (padded or unpadded) to text. Raises on invalid base64 or when the decoded bytes are not valid UTF-8, instead of silently inserting replacement characters.

Str.fromBase64 "Y2Fmw6k="

Str.fromUtf8

Str.fromUtf8 (b: Bytes) : string

Decode Bytes as UTF-8 text. Raises when the bytes are not valid UTF-8 or contain a NUL byte, so corrupt data is never decoded silently.

Str.isEmpty

Str.isEmpty (s: string) : bool

True for the empty string "" (whitespace is not empty; Str.trim first for that).

Str.isEmpty ""

Str.isMatch

Str.isMatch (pattern: string) (subject: string) : bool

True when a regex matches anywhere in the string.

Str.isMatch "[0-9]+" "x42"

Str.join

Str.join (sep: string) (xs: seq<string>) : string

Join a sequence of strings with a separator.

Str.join "," ["a"; "b"]

Str.length

Str.length (s: string) : int

The number of characters (Unicode code points).

Str.length "abc"

Str.nonEmpty

Str.nonEmpty (s: string) : bool

True for any string but "" — the point-free filter: lines |> Seq.where Str.nonEmpty.

["a"; ""] |> Seq.where Str.nonEmpty

Str.padLeft

Str.padLeft (width: int) (s: string) : string

Pad with spaces on the left to a total width, for right-aligned columns. A string already that long is unchanged; a negative width raises.

Str.padLeft 5 "42"

Str.padRight

Str.padRight (width: int) (s: string) : string

Pad with spaces on the right to a total width, for left-aligned columns (like printf "%-15s"). A string already that long is unchanged; a negative width raises.

Str.padRight 6 "name"

Str.replace

Str.replace (old: string) (new: string) (s: string) : string

Replace every occurrence of a substring.

Str.replace "a" "b" "aba"

Str.replicate

Str.replicate (n: int) (s: string) : string

The string repeated n times. 0 gives the empty string; a negative count raises, as in Seq.replicate.

Str.replicate 3 "ab"

Str.rmatch

Str.rmatch (pattern: string) (s: string) : Option<seq<string>>

The groups of the first regex match as Some sequence, or None when there is no match. Groups are positional: named groups (?<x>...) are rejected, because in weir you name captures when you bind them.

Str.rmatch "([0-9]+)" "x42"

Str.rmatchAll

Str.rmatchAll (pattern: string) (s: string) : seq<seq<string>>

Every regex match's groups, as a sequence of sequences.

Str.rmatchAll "[0-9]+" "a1b2" |> Seq.freeze

Str.rsplit

Str.rsplit (pattern: string) (s: string) : seq<string>

Split on every regex match. As with split, empty pieces are kept (adjacent matches, or a match at either end, produce ""). Capture groups do not add extra pieces.

Str.rsplit @"\s*,\s*" "a , b,c" |> Seq.freeze

Str.rsplitOnce

Str.rsplitOnce (sep: string) (s: string) : string * string

Split at the last occurrence of the separator into (before, after). Raises when the separator is missing; tryRsplitOnce returns an Option instead. Useful for host:port when the host may contain colons.

Str.rsplitOnce ":" "[::1]:8080" |> snd |> print

Str.sha256

Str.sha256 (s: string) : string

The SHA-256 digest of the string's UTF-8 bytes, as lowercase hex (same output as sha256sum). Only SHA-256 is available for now.

Str.sha256 "hello"

Str.split

Str.split (sep: string) (s: string) : seq<string>

Split on a separator into a sequence. Empty pieces are kept: adjacent separators, or a separator at either end, produce "".

Str.split "," "a,b,c" |> Seq.freeze

Str.splitOnce

Str.splitOnce (sep: string) (s: string) : string * string

Split at the first occurrence of the separator into (before, after); the after part is kept whole, including any later separators. Raises when the separator is missing; trySplitOnce returns an Option instead.

Str.splitOnce "=" "key=a=b" |> snd |> print

Str.startsWith

Str.startsWith (prefix: string) (s: string) : bool

True when the string starts with a prefix.

"abc" |> Str.startsWith "a"

Str.sub

Str.sub (start: int) (len: int) (s: string) : string

A substring by start index and length, both in characters (code points).

Str.sub 0 2 "abc"

Str.toBase64

Str.toBase64 (s: string) : string

Base64 of the string's UTF-8 bytes, on one line. It is never wrapped at 76 columns, so there is no need for base64 -w0.

Str.toBase64 "café"

Str.toInt

Str.toInt (s: string) : int

Parse an int (raises on a non-number).

Str.toInt "42"

Str.toLower

Str.toLower (s: string) : string

Lowercase (invariant culture).

Str.toLower "ABC"

Str.toUpper

Str.toUpper (s: string) : string

Uppercase (invariant culture).

Str.toUpper "abc"

Str.toUtf8

Str.toUtf8 (s: string) : Bytes

The string's UTF-8 bytes as a Bytes value.

Str.toUtf8 "café"

Str.trim

Str.trim (s: string) : string

Drop leading and trailing whitespace.

Str.trim "  x  "

Str.trimEnd

Str.trimEnd (s: string) : string

Drop trailing whitespace.

Str.trimEnd "x  "

Str.trimStart

Str.trimStart (s: string) : string

Drop leading whitespace.

Str.trimStart "  x"

Str.tryFromBase64

Str.tryFromBase64 (s: string) : Option<string>

Like fromBase64, but returns an Option: None both for malformed base64 and for valid base64 that does not decode to text.

Str.tryFromBase64 "!!!"

Str.tryFromUtf8

Str.tryFromUtf8 (b: Bytes) : Option<string>

Like fromUtf8, but returns an Option: None when the bytes are not text (including bytes with NUL).

Str.tryIndexOf

Str.tryIndexOf (needle: string) (s: string) : Option<int>

The index of a substring as an Option.

Str.tryIndexOf "b" "abc"

Str.tryRsplitOnce

Str.tryRsplitOnce (sep: string) (s: string) : Option<string * string>

Like rsplitOnce, but returns an Option: Some (before, after) at the last occurrence, None when the separator is missing.

match Str.tryRsplitOnce "@" "a@b@host" with | Some (u, h) -> print h | None -> print "no"

Str.trySplitOnce

Str.trySplitOnce (sep: string) (s: string) : Option<string * string>

Like splitOnce, but returns an Option: Some (before, after) at the first occurrence, None when the separator is missing. Handy for parsing KEY=VALUE.

match Str.trySplitOnce "=" "key=val" with | Some (k, v) -> print k | None -> print "no"

Str.tryToInt

Str.tryToInt (s: string) : Option<int>

Parse an int as an Option, None when it is not a number.

Str.tryToInt "42"

Table

Table.inferShape

Table.inferShape (lines: seq<string>) : string

Generate a draft row type declaration from a sample of column-aligned output and return it as text; this is what #infer … from table uses. Each column's type comes from its values: int if all are ints, otherwise float or bool if they all fit, otherwise string; a column with empty or <none> cells becomes an Option, with a note. Headers become field names, with [<Wire>] added when the original header cannot be recovered from the name. A note says the result is read as seq<Root>. Review and edit the draft. This runs on a sample at runtime; it does not affect type checking.

print (Table.inferShape ["NAME   RESTARTS"; "web-1  0"])

Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from table behave.

Table.render

Table.render (rows: seq<'a>) : seq<string>

Format a seq of records of the same type as aligned table lines for print, with a bold header and the same layout the REPL uses. It is for display only: there is no to table, and a table cannot be read back reliably, so use to json or to yaml to exchange data. The table is fitted to the terminal width, and not limited when piped. A seq of non-records is an error that points at the location.

ls |> Table.render |> Seq.iter print

Term

Term.width

Term.width () : int

The terminal width in columns, read each time it is called, so resizes are picked up. Returns 80 instead of raising when there is no terminal (output piped or redirected). Use it to size output, like tput cols.

print (show (Term.width ()))

Tree

Tree.walk

Tree.walk (step: 'a -> seq<'a>) (root: 'a) : unit

Walk a tree for its side effects, parents before children. The step function does the work for a node and returns its children, so which children exist can depend on what the parent did. Nodes are not de-duplicated; for structures with cycles use Graph.reach or Frontier.fold. The 100000-step limit still applies.

1 |> Tree.walk (fun n -> if n < 3 then [n + 1] else [])

Uuid

Uuid.fromBytes

Uuid.fromBytes (b: Bytes) : Uuid

The uuid of 16 big-endian bytes. Raises on any other length.

Uuid.nil |> Uuid.toBytes |> Uuid.fromBytes

Uuid.instant

Uuid.instant (id: Uuid) : Option<Instant>

Some creation instant for a version 7 uuid (millisecond precision); None for every other version.

Uuid.parse "017f22e2-79b0-7cc3-98c4-dc0c0c07398f" |> Uuid.instant

Uuid.max

Uuid.max : Uuid

The all-ones uuid. It sorts after every other uuid.

show Uuid.max

Uuid.nil

Uuid.nil : Uuid

The all-zero uuid, used to mean "no uuid".

show Uuid.nil

Uuid.ns

Uuid.ns : UuidNamespaces

The RFC 9562 namespaces for v5: dns, url, oid, x500. Any uuid works as a namespace of your own.

Uuid.ns.url

Uuid.parse

Uuid.parse (text: string) : Uuid

Parse a uuid: 8-4-4-4-12 hex digits or 32 digits without dashes, in any case, with an optional urn:uuid: prefix. Every version and variant is accepted. Raises on anything else; use tryParse to get an Option instead.

Uuid.parse "urn:uuid:F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6"

Uuid.toBytes

Uuid.toBytes (id: Uuid) : Bytes

The 16 bytes in big-endian (network) order, the same order as the text form.

Uuid.nil |> Uuid.toBytes |> Bytes.toBase64

Uuid.toString

Uuid.toString (id: Uuid) : string

The standard text form: lowercase 8-4-4-4-12. This is what show, JSON and command arguments use.

Uuid.toString Uuid.nil

Uuid.tryParse

Uuid.tryParse (text: string) : Option<Uuid>

Some uuid, or None when the text is not one (including braces and other platform-specific forms).

Uuid.tryParse "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}"

Uuid.v4

Uuid.v4 () : Uuid

A random uuid (version 4), from the system's cryptographic generator.

Uuid.v4 () |> Uuid.version

Uuid.v5

Uuid.v5 (ns: Uuid) (name: string) : Uuid

The name-based uuid (version 5): SHA-1 over the namespace's bytes and the name's UTF-8 bytes. The same name in the same namespace always gives the same uuid.

Uuid.v5 Uuid.ns.dns "www.example.com"

Uuid.v7

Uuid.v7 () : Uuid

A time-ordered uuid (version 7): epoch milliseconds, then a counter and random bits. Values are strictly increasing within this process, so they sort by creation time both as uuids and as text.

Uuid.v7 () |> Uuid.instant

Uuid.v7At

Uuid.v7At (t: Instant) : Uuid

A version 7 uuid for the given instant, truncated to milliseconds. Use it for records that carry their own timestamp. If the instant is between the last generated uuid and now, it sorts correctly with v7 uuids; an earlier or future instant gets random bits and no ordering guarantee. Raises for instants before the Unix epoch.

Uuid.v7At (Instant.parse "2022-02-22T19:22:22Z") |> Uuid.instant

Uuid.version

Uuid.version (id: Uuid) : int

The version field: 4, 5, 7 and so on (0 for nil, 15 for max). Meaningful for the RFC 9562 variant.

Uuid.parse "017f22e2-79b0-7cc3-98c4-dc0c0c07398f" |> Uuid.version

Yaml

Yaml.inferShape

Yaml.inferShape (lines: seq<string>) : string

Generate draft type declarations from a YAML sample and return them as text; this is what #infer uses. The top record is named Root and nested records are named automatically. Array elements are merged, and a key missing from some elements becomes an Option. An object used as a dictionary (values of one shape with mostly non-identifier keys, different keys in sibling objects, or an empty {}) becomes seq<string * _>. Notes are added as // lines. The draft only covers what the sample contains, so review and edit it. This runs on a sample at runtime; it does not affect type checking.

let sample = <<<
    name: web
    port: 8080
print (Yaml.inferShape sample)

Like weir add schema, this turns outside data into a type declaration you paste into your script and own; it does not change how check or from yaml behave.

Yaml.merge

Yaml.merge (patch: YamlPatch) (doc: Yaml) : Yaml

Apply a yaml patch block to a document. The patch's structure says where each change goes: mappings are merged recursively; sequence items are appended if missing, or updated/removed by the key given with by=<key>; scalars are replaced; $- removes a key or a matching item. The order of entries does not matter and applying a patch twice changes nothing more. Write the result with to yaml.

Define the patch with let p = yaml patch by=name and indented patch lines, then run File.read f |> Yaml.parse |> Yaml.merge p |> to yaml |> File.write f.

Yaml.parse

Yaml.parse (lines: seq<string>) : Yaml

Parse one YAML document (the same strict subset) into Yaml nodes without a declared type. Every key is kept, including ones from yaml T would drop. Scalar types follow the same rules as in a yaml block: unquoted true/3/1.5 become YBool/YInt/YFloat, quoted or block scalars become YStr, and empty values become YNull.

["replicas: 3"] |> Yaml.parse

Forms

Completed

A finished command: exitCode, stdout, stderr. You get one from | complete.

EnvVar

A name/value environment pair. From Env.vars / pair / ofPairs / fromFile.

FileRow

A directory entry, as returned by ls. Fields: name; kind (Regular, Directory or Symlink); target (Some link target for a symlink, None otherwise; no File.* function gives you this); bytes (0 B for a directory); modified (the last-write time as an Instant, which does not change after you read it; tables show it as relative time, show prints ISO); hidden; and path. ls lists files and subdirectories sorted by name, case-sensitively with uppercase first, regardless of locale. Use name for matching and display and path when calling File.* functions. Less common details are separate functions rather than fields, such as File.mode for permissions.

cd

cd (path: string) : string

Change the session's directory and return the previous one, so you can go back later (let prev = cd "/tmp"). A bare name uses the value of that binding (cd target). cd with no argument, or a typed cd ~, goes to the home directory; a string argument is used literally. To change directory for a block only, use within cd.

cd "."

complete

Run the command (or pipeline) and capture everything as a Completed record: its exit code, stdout and stderr. A nonzero exit does not raise.

Use it when you need the exit code and the output together: let r = git push | complete, then read r.exitCode, r.stdout, r.stderr.

exec

Replace weir's process with the command (execve), so it never returns. The program keeps weir's process id, which means a container entrypoint receives signals directly. It cannot take piped stdin.

Like fail and exit, it ends the script, so it fits in any branch, whatever type the other branches have.

exit

exit (code: int) : 'a

Exit the process with a status code.

exitCode

Run the command with its output streaming to the terminal and return its exit code as an int. It never raises.

Bind or match the result, as in let rc = make test | exitCode. Using it as a bare statement is an error, because the code would be thrown away.

fail

fail (message: string) : 'a

Stop with a message and exit code 1.

Use exit n to exit with a code and no message. fail never returns (its type is string -> 'a), so it can stand in any branch, for example opposite a branch that returns a value.

freeze

freeze (xs: seq<'a>) : seq<'a>

Evaluate a lazy sequence now and keep the results. Same as Seq.freeze.

[1; 2; 3] |> freeze

from json

Parse one JSON document, which may span several lines (so a pretty-printed HTTP body can be piped straight in), into a declared record. Fields can be int, string, bool, float, or an Option of one of those; a missing key or null becomes None.

Use it as a pipe stage: resp.body |> from json Config.

from jsonl

Parse JSON lines (NDJSON, one document per line, as to jsonl writes) into declared records.

Use it as a pipe stage: xs |> from jsonl Config.

from table

Parse column-aligned tool output (a header row followed by aligned rows) into declared records, giving a seq<T>. Columns are cut at the header positions, not at every space, so a value with spaces (Up 2 hours) stays whole; headers are separated by 2 or more spaces (CONTAINER ID is one column). Fields match headers by their letters and digits, ignoring case (podTemplateHash matches POD-TEMPLATE-HASH); [<Wire "HEADER">] matches a header exactly. Cells are trimmed and converted to the field type (string, int, float, bool); for an Option field, an empty or <none> cell becomes None. Extra columns and blank lines are ignored, and errors report the line and column. Reading only; there is no to table.

Use it as a pipe stage on a tool's table output: … |> from table Pod.

from toml

Parse one TOML document (TOML 1.1, so every 1.0 file too) into a declared record, the same way from yaml does: nested records for tables, seqs for arrays and arrays of tables, Option for a key that may be missing, [<Wire "key">] for a key that is not a weir name (current-context, requires-python). Duplicate keys and redefined tables are errors, with the line. A date or time is read as its text; an offset date-time can be read into an Instant field with [<Iso8601>]. Reading only; there is no to toml.

Use it as a pipe stage: File.read "Cargo.toml" |> from toml Cargo.

from xml

Parse one XML document (a .csproj, .slnx or any XML) into a declared record. The root element is the record, and each field matches a child element by local name (a default xmlns is ignored). Use [<Attr>] to read an attribute, [<Elem "X">] for a repeated child, and a nested record for a child element. All values are text: fields are string, Option<string>, a record, or a seq of these, so declare a number as string and convert it with Str.toInt. Reading only; there is no to xml.

Use it as a pipe stage: File.read "App.csproj" |> from xml Proj.

from yaml

Parse YAML into declared records. Supports a strict subset: block mappings and sequences, scalars, # comments, and multiple documents separated by ---. Fields can be nested records, seqs, seq<string * _> for mappings, and Option (a missing key or null becomes None). Anchors, tags and flow style are rejected.

Use it as a pipe stage: lines |> from yaml Deployment gives a seq<Deployment>, one per YAML document.

id

id (x: 'a) : 'a

The identity function: returns its argument unchanged. Useful where a function is expected, as in Seq.choose id over a seq of Options. Unlike not or fst, id can still be used as a variable name; a binding named id simply shadows it.

[Some 1; None; Some 3] |> Seq.choose id

let pure

Mark a binding as pure: let pure f x = … asserts that its whole body has no side effects, and weir check reports an error if it does. It works the same as wrapping the body in a pure block; hover shows the binding with a (pure) badge.

let pure double n = n * 2

See #help pure for the block form and the list of effects.

line

Run a one-value command and return its single line of output as a trimmed string — for tools like git rev-parse HEAD or az … -o tsv. Raises on a nonzero exit, or when the output is not exactly one line.

For several lines use | text (one string), or bind the command itself (a seq<string>, one element per line).

not

not (b: bool) : bool

Boolean negation.

not true

orFail

Run the command with its output streaming to the terminal; if it exits nonzero, raise with your message and the exit code.

Use it to stop the script when a command fails: make test | orFail "tests failed".

poll

Like retry, but bounded by time instead of attempts: poll timeout=5m interval=10s followed by a body that checks readiness. With watch=<proc>, poll fails immediately if that scoped process dies (the error includes its last output), and a timeout error reports the process's state.

poll timeout=1s interval=1ms true

To wait for a server to come up, start it with within proc srv = …, then poll timeout=10s watch=srv with Net.portOpen <port> as the body.

print

print (value: 'a) : unit

Write a value and a trailing newline to stdout. Takes a string, int, float, bool, or seq<string> (one line per element); for anything else, render through an interpolation hole ($"{row}") or show.

print "hi"

printerr

printerr (value: 'a) : unit

Write a value and a newline to stderr. Use it for diagnostics and keep stdout for data. Accepts the same types as print: string, int, float, bool, or seq<string>.

printerr "oops"

pure

Assert that an indented block has no side effects: no filesystem, commands, network, environment, console or clock. If the block can reach one, weir check reports an error naming it. This is opt-in; code outside a pure block is not restricted.

pure 1 + 1

To mark a whole function, use let pure. Calls the checker cannot see into (a function parameter, a member of an import) count as impure, so the check may reject code that is actually pure, but it never accepts code that is not.

retry

Re-run an indented body until it succeeds: retry attempts=5 delay=30s followed by the body. If the body returns a bool, true means success. If it returns another value, add until r and a predicate block; retry then returns the value that passed. Running out of attempts raises. An error raised inside the body is not retried, it propagates; to retry a failing command, turn its result into data with | succeeds or | complete.

retry attempts=2 delay=1ms true

The options are a record, so you can also write retry { Retry.defaults with attempts = 3 }.

show

show (value: 'a) : string

Turn any value into its text form, the same text string interpolation produces. Use it where interpolation does not fit, such as Seq.map show. A Secret cannot be interpolated, but show renders it masked. It works on every value; functions show as an opaque placeholder.

[1; 2; 3] |> Seq.map show |> Seq.freeze

succeeds

Run the command and return true if it exited 0. Its output is discarded.

Use it in a condition: if git diff --quiet | succeeds then ….

text

Run the command and return its whole output as one string: lines joined with newlines, trailing blank lines dropped (like bash's $(…)). Raises on a nonzero exit.

To keep the exit code instead of raising, use | complete.

to json

Write one value as a single-line JSON document. A record becomes an object and a seq becomes an array (the whole seq is read first). A None field is left out, so from json reads it back as None.

Use it as a pipe stage: payload |> to json.

to jsonl

Write a sequence as JSON lines (NDJSON, one document per element, as from jsonl reads), lazily.

Use it as a pipe stage: xs |> to jsonl.

to yaml

Write one value as a YAML document. A record becomes a mapping, a seq a sequence, and a seq of pairs a mapping. to yaml stream writes one document per element, separated by ---. A None field is left out; strings that YAML could misread as another type (no, 007, 1e5) are quoted.

Use it as a pipe stage: deployment |> to yaml.

try

Turn a failure into a value: <expr> |> try gives Some value, or None if evaluating the expression raised (a failed command, a parse error, a fail, a missing file). <expr> |> try result gives Ok value or Error message instead. Everything to the left of |> try is captured, and its value is fully evaluated inside, so a lazy sequence cannot fail later. exit still exits, and effects that happened before the failure are not undone.

File.read "Cargo.toml" |> from toml {| package: {| version: string |} |} |> try

Commands have their own form: <command> | complete gives the exit code and output as a record.

within

Open a resource for an indented block. It is released however the block exits, normally or by an error. The kinds: tmp — a fresh directory, removed when the block exits; cd — the working directory for the block, restored after; env — environment variables set for the commands the block runs; proc — a background process, stopped together with anything it started when the block exits; serve — an HTTP server that runs until the block exits; its port is freed however the block ends; lock — an advisory file lock, held for the block and released however it ends, even on kill -9.

tmp a fresh directory, removed when the block exits
cd the working directory for the block, restored after
env environment variables set for the commands the block runs
proc a background process, stopped together with anything it started when the block exits
serve an HTTP server that runs until the block exits; its port is freed however the block ends
lock an advisory file lock, held for the block and released however it ends, even on kill -9

each kind's laws and examples: Scopes

within tmp d
    print d

within proc srv = <command> binds a Proc handle; the process and its children are killed and reaped when the block exits.