weir
On this page

Coming from another language

You already know how to write scripts; this page shows what changes. For each language: what you write today, what you write in weir, and the one thing most likely to trip you up. The guide teaches weir from scratch; this page translates.

Coming from bash / POSIX sh

The biggest change: $x always passes exactly one argument, and $@xs passes one argument per element. Nothing is ever split on spaces or joined back together. The quoting habits you spent years building ("$x", "$@", IFS) are simply how weir always behaves, so word splitting, and the injection bugs that come with it, can’t happen.

bashweir
out=$(git branch)let out = git branch gives a seq<string>, one element per line
for f in *.txt; do … donefor f in Path.glob "*.txt" do …
if grep -q pat f; thenif grep -q pat f | succeeds then
cmd > out.txtcmd |> File.write "out.txt"
$?cmd | exitCode (the output still prints; you get the code as an int)
a && b / a || ba | and b / a | or b
FOO=1 cmdFOO=1 cmd, same as bash
$(pwd)pwd, already a string
cat <<EOF … EOF | cmdlet lines = <<< + an indented block, then lines | cmd
# comment// comment, on its own line or after code (with a space before it)
cmd a \ + newlineindent the next line instead; a trailing \ is an error that tells you so
find … -exec cmd {} \;find … -exec cmd {} ";" (no backslash escapes in commands, so quote the word)
\ls (skip the alias)^ls runs the program from PATH
[ "$a" != "$b" ]a <> b (!= is an error that points you to <>)
cp "$f" "$f.bak"cp $f $"{f}.bak"

A few notes on those rows:

let msg = "two words"
printf "[%s]" $msg

let branches = $(git branch) |> Seq.length
print $"branches: {branches}"

That printf gets one argument, brackets and all. Nothing is re-split.

The one thing that will catch you out: a capture gives you lines, not a string. In bash, $(cmd) is one string with trailing newlines stripped. In weir it’s a seq<string>, one element per line. When you pipe a value back into a command (expr | cmd), each element is written as a line, newline included. So ["x"] | sha256sum hashes x\n: the same as printf 'x\n' | sha256sum, not printf 'x' | sha256sum. This is one of the few places where weir is less convenient than bash, so take care when hashing. When you want a single string, use cmd | text (all of stdout) or cmd | line (exactly one line).

set -e is always on and has no name. A nonzero exit raises an error (once the command’s output has been read to the end) and stops the script, pointing at the failing line. To inspect a failure instead, turn it into data: cmd | complete gives { exitCode; stdout; stderr }. That record is also how you get at stderr where bash would use 2>&1.

// set -e is unconditional and unnamed: a nonzero exit raises
sh -c "exit 3"
print "unreached"

Not here, and what to write instead:

Coming from fish

Fish already fixed the parts of bash that weir rules out: variables are lists, expansion never splits on spaces, and (cmd) splits on newlines rather than IFS. weir keeps all three habits and adds types. A capture is a seq<string>, each list element is exactly one argument, and string functions live in a module (Str) rather than the string command.

fishweir
set out (git branch)let out = git branch, a seq<string> with one element per line (the newline split you already expect)
echo $files (one word per element)echo $@files; spreading a list into arguments is explicit, and $x is always exactly one argument
set files *.txt (glob in argv)let files = Path.glob "*.txt", a function that returns a seq
string split , $s / string trimStr.split "," s / Str.trim
string match -r 'v(\d+)' $smatch s with | Regex @"v(\d+)" v -> v | _ -> "0" (v is typed, and the no-match arm is required)
if test -f $pathlet ok = test -f $path | succeeds then if ok then
$statuscmd | exitCode (the output still prints; you get the code as an int)
count $filesfiles |> Seq.length
function deploy; …; end ($argv)let deploy target = …, with named, typed parameters instead of $argv[1]
for f in (cat list.txt); …; endfor f in File.read "list.txt" do …
let words = ["a b"; "c"]
printf "<%s>" $@words

let lines = $(printf "one\ntwo") |> Seq.map Str.toUpper
lines |> Seq.iter print

That printf gets a b as one argument, then c: fish’s list rule, kept. The capture splits on newlines, like fish’s command substitution, but now it has a type.

The one thing that will catch you out: in fish, an unset or empty variable expands to zero arguments and the command runs anyway. weir rejects the script before anything runs. $x is always exactly one argument: an empty seq can’t silently drop out of a command line, an unbound name is a check error, and a seq only spreads into several arguments through $@x. Where fish would quietly change how many arguments a command gets, weir makes that count visible in the code.

// fish: an unset $nope expands to nothing and echo runs bare;
// weir: an unbound name is a check error before line one
echo $nope

Not here, and what to write instead:

Coming from F#

weir looks like F# on purpose (pipelines, records, unions, match including function, indentation-based blocks), but it is not F#. Every difference is listed in tests/fidelity/divergences.md. The short version: no mutation, no exceptions, no classes or objects, no async (parallelism is Seq.pmap/piter; the TypeScript section shows how), no computation expressions, no let rec, and no implicit widening.

F#weir
if x = y thenif x == y then; = is only for let and record fields
printfn "%d files" nprint $"{n} files"; there’s no printf family, so use interpolation
try … withe |> try (an Option) or e |> try result (a Result<'T, string>); for a command, cmd | complete
try … finallya bare within + always block
while / let recneither exists; let rec is a parse error ('rec' is a keyword)
open Seqno open; names are always qualified. Share code with import "./lib/x.weir" as X
.fsi / .mli signature fileswrite the signature inline, let f : int -> int (no =), above the definition
| MyMod.Case v -> qualified cases in patternswrite the case bare: | Case v -> (| X.Case v -> is a parse error)
$@"…" / $$"""…"""only $"""…{hole}…"""; a literal brace in $"…" is {{
[| 1; 2 |] arrays, listone sequence type, seq<'a>; [1; 2] literals are eager
{| ip = "x" |} anonymous recordsthe same syntax and types, with a few limits (below)
(+) and (>) 10(+) works (Seq.reduce (+)), but partially applied operators don’t; write the lambda

The longer story behind some of those rows:

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

Equality, show and sorting work generically, through constraints that weir infers. The set of constraints is fixed by the compiler; you can’t define your own type classes.

The one thing that will catch you out: == versus =, from the first minute. == is weir’s equality and an error in F#; = is F#‘s equality and weir’s binding. (In weir, 0.1 == 0.2 is a check error either way: floats can’t be compared with ==, because they’re finite-only here. Use Float.near a b eps. See GUIDE.md.)

Equality on collections surprises you in the other direction. weir’s == rejects seqs at check time. That looks like a limitation until you see what F# does: = on a seq<'T> compiles, but the answer depends on the runtime type. It’s structural if the value happens to be a list or array, and reference equality for a computed seq, so Seq.map id [1;2] = Seq.map id [1;2] is false. weir would rather refuse than give an answer that depends on where the value came from. Compare what you actually mean: Seq.length, a Str.joined string, or an element-by-element check.

What F# only warns about, weir treats as an error, reported before anything runs: a non-exhaustive match, a discarded non-unit value, an unreachable arm below a catch-all, and a | that’s off by one column.

// F# warns FS0025 and runs anyway; weir refuses before line one
let word = match 1 with | 1 -> "one"
print word

Not here, and what to write instead:

Coming from PowerShell

The first thing to know: weir does carry structured values through pipelines. ls yields typed rows, from json T gives a record of a shape you declared (from jsonl T gives a stream of them), and _.name reads a field of the piped value. It works on any record that has the field, which is what you’d expect from Where-Object, and not what you’d expect from a statically typed language.

PowerShellweir
Get-ChildItem | Where-Object Length -gt 1kbls |> Seq.where (fun f -> f.bytes > 1KiB)
… | ForEach-Object { $_.Name }… |> Seq.map _.name
ConvertFrom-Json|> from json T, against a shape you declared
"$($x.Name) ready"$"{x.name} ready"
$LASTEXITCODEcmd | exitCode
git status --porcelain |> Seq.choose (fun l -> match l with | Regex @"^.. (.*)$" path -> Some path | _ -> None) |> print

The one thing that will catch you out: there are two pipes, not one. PowerShell has a single object pipeline: cmdlets take and produce objects. In weir, | carries text to and from external programs, and |> carries values between functions. What’s on the right decides which one you need, and using the wrong one gives an error that names the other. Turning text into values is up to you. PowerShell’s cmdlets do that conversion for you, but weir runs ordinary programs that print text, and from json T / from yaml T are where that text becomes typed data, in the shape you declared.

The trade-off cuts both ways. PowerShell’s types stop at the cmdlet boundary: a plain .exe gives you strings, and Where-Object Length looks up the property at run time, so a misspelled name matches nothing and silently filters out everything. In weir, types start right at the boundary with external programs: from json T is checked before anything runs, and a misspelled field is a check error with a did-you-mean.

Not here, and what to write instead:

Coming from Nushell

Nushell is weir’s closest neighbour. A structured, typed pipeline where ls | where size > 1kb | length carries real values rather than text to re-parse is exactly the idea weir shares. Most of your habits carry over, safety included: on both, a spliced variable is one argument (^echo $x with $x = "a b" passes a single word, and so does weir’s echo $x), so neither has word-splitting injection bugs.

Nushellweir
ls | where size > 1kbls |> Seq.where (fun f -> f.bytes > 1KiB)
$rows | each { |r| $r.name }rows |> Seq.map _.name
'…' | from json | get host… |> from json T, then r.host
^cmd | complete | get exit_codecmd | complete, then .exitCode
$"(…)"$"{…}"
ls |> Seq.where (fun f -> f.bytes > 1KiB) |> Seq.length |> print

Where the two part company: when the check happens. Nushell parses and type-checks before running. A bad operation like "s" + 5 is a parse error even in a branch that never runs, which is more than most shells do. But the check doesn’t cover commands or data shapes. An external command is looked up only when execution reaches it, so a script can run its first half and then fail on a missing command (print "before"; frobnicate prints before, then errors). Parameter types are advisory (def greet [name: string] accepts greet 5). And from json gives a dynamic record, so a missing field is a run-time column_not_found error at the line that reads it.

weir moves all three to check time. Every command name is resolved before the first line runs, including those in branches that never run, and weir check --can lists every command the script could run. So a missing tool never lets the first half run: the same print "before" / missing-command file is refused with nothing printed. from json T, Args.load T and yaml schema= name the shape, so a missing field, an unknown flag or a misspelled manifest key is a check error with a did-you-mean, not a surprise at run time.

The trade, honestly: nu is an interactive shell first, with a REPL you live in, a plugin system and a large library of structured commands. weir isn’t trying to be that (its REPL is for trying out weir code, not for living in). What weir adds is the up-front check. When a nu one-liner grows into a script, it fails at the line that breaks; the weir version fails when you check it, before any side effect. If you want a shell, use nu. If you want the script it turned into, checked as a whole, use weir.

Coming from Python

Start with what a Python script can’t do. First, the whole weir file is checked before the first line runs, including that every command you call exists. (weir check only warns about a missing command, so you can keep editing scripts for tools you haven’t installed; running the script refuses before doing anything.) Second, subprocess.run("… " + arg, shell=True) has no equivalent, and can’t have one: arguments are data, a splice is one argument, and no shell ever re-parses your command line.

Pythonweir
subprocess.run(["git", "add", f])git add $f
f"{n} files"$"{n} files"
with tempfile.TemporaryDirectory() as d:within tmp d + an indented block
os.environ.get("PORT")Env.get "PORT", or Env.load Config for typed settings (one error reports every bad field)
argparseArgs.load Cli; the flags come from a record you declare
json.loads(...) → dict soup|> from json T → your declared record
requests.post(url, json=payload)Http.send { Http.defaults with method = Post; url = u; body = Json (payload |> to json) }
-7 % 3 == 2 (floored)-7 % 3 == -1 (truncated, as in F#, .NET and C)

With Http.send, the response status is data you inspect, auth can be a Secret, and the body is sent byte for byte. weir’s % takes the sign of the left operand; it agrees with Python whenever both operands are positive.

type Cfg = { name: string; port: int }
let text = """{ "name": "api", "port": 8080 }"""
let cfg = [text] |> from json Cfg
print $"{cfg.name}:{cfg.port}"

The one thing that will catch you out: indentation matters, but differently. No : opens a block; a block is the lines indented under the line that starts it. A statement starts at column 0 and runs until the next line at column 0, and blank lines inside a statement don’t end it. In practice: an if body is just indented lines, and the next line back at column 0 is the next statement.

Python’s with is weir’s within, and it covers more than files. within tmp d creates a fresh directory and removes it however the block exits, errors included. within cd and within env scope the working directory and the child processes’ environment the same way.

within tmp d
    ["data"] |> File.write $"{d}/f.txt"
    let back = File.read $"{d}/f.txt"
    print (back |> Seq.head)

Not here, and what to write instead:

Coming from TypeScript / Node scripting (zx, execa, bun $)

zx also calls itself “a real language for shell scripts”, so the differences are easy to see. There are two. weir checks the whole file before running a line: types, fields, match coverage, and whether the commands exist. zx finds a misspelled binary only when it reaches that await, halfway through the deploy. And there’s no runtime to install: one native binary, millisecond startup, no node_modules, no package.json.

zx / Nodeweir
await $`git status` git status
$`git add ${file}` (zx escapes)git add $file; always one argument, nothing to escape
$.nothrow / .exitCodecmd | complete / cmd | exitCode
await Promise.all(xs.map(f)) / p-mapxs |> Seq.pmap f
await Promise.any(xs.map(f))xs |> Seq.pfirst f
globby, fs/promisesPath.glob, File.* / Dir.*
zod schema .parse(...) at runtimefrom json T and vendored JSON schemas, checked before the script runs
await fetch(url).then(r => r.json())Http.get u |> Http.expect |> from json T (raises on an error status); for the status as data, use Http.send

Seq.pmap runs a bounded number of calls at once (Seq.pmapWith n sets the limit) and returns results in input order; if calls fail, you get the first failure in input order. Seq.pfirst returns the first call to succeed. The rest are stopped (their process trees killed) and their failures ignored; Seq.pfirstWith n sets the limit.

let target = "seed.txt"
git add $target
[1; 2; 3] |> Seq.pmap (fun n -> n * n) |> Seq.map show |> print

The one thing that will catch you out: the habit of gluing values into template literals. In zx, --file=${f} interpolates into the command string. In weir a splice is a whole argument, and gluing one into the middle of a word is an error. Write --file $f, or build the word first: $"--file={f}".

// the zx habit: a mid-word splice is a hard error, not an escape
let f = "seed.txt"
echo --file=$f

Not here, and what to write instead:

Coming from Make

Start with what weir doesn’t have. Make is a dependency graph with rules for what’s out of date; weir is a script. There’s no target: prereq, no timestamp comparison, no skipping work that’s up to date, so if that’s what you’re after, it isn’t here. What you get instead: real types, errors before any side effect, and none of the recipe quirks: no tabs-versus-spaces, no new shell per line, no $$ escaping, no .PHONY.

Makeweir
$(VAR)$var
$(shell git rev-parse HEAD)let sha = git rev-parse HEAD | line
$@, $< automatic variablesordinary named bindings (and see the false friend below)
VAR = … vs VAR := …each let is evaluated once, in order, so there’s no difference to make
.PHONY: deploynot needed: a weir file is a script, not a set of targets
let sha = git rev-parse --short HEAD | line
print $"building {sha}"

The one thing that will catch you out: in Make, every recipe line runs in its own shell, so a cd on one line is gone on the next. A weir file is one program: a cd lasts until you scope it (within cd "dir" changes back when the block ends). And $@ is a true false friend: in Make it’s the target, in weir it spreads a list into arguments ($@xs passes one argument per element).

Not here, and what to write instead: targets ordered by dependency. Keep the graph in Make (or a task runner) and call weir from the recipe; the next section covers that.

Coming from Just / Task / npm-scripts

Task runners are thin wrappers around shell strings, and weir is a language you’d write the recipe body in. So it isn’t weir versus just: weir replaces the shell inside the recipe, and a justfile whose recipe line is weir deploy.weir --env prod is a perfectly good end state.

What weir adds inside a recipe: the whole script is checked before anything runs; the script’s own flags are declared, typed, and get a generated --help; and arguments stay intact from start to finish.

type Cli = {
    [<Default "dev">]
    /// target environment
    env: string
}
let cli = Args.load Cli
print $"deploying to {cli.env}"

What weir does not do: task discovery, --list, ordering tasks by their dependencies, a self-documenting menu. Those stay the runner’s job. This section is short on purpose: the two tools overlap little and work well together.

Coming from Ruby

A pleasant surprise first: weir’s row polymorphism is the nearest thing to duck typing, but static. Seq.map _.name works on any record that has a name field, and it’s checked before the script runs.

Rubyweir
`git status`, %x{git status}$(git status), a seq<string> of lines
system("git", "add", f)git add $f
Dir.glob("**/*.rb")Path.glob "**/*.rb"
xs.map { |x| x.strip }xs |> Seq.map Str.trim
OptionParserArgs.load Cli
$(git log --oneline -1) |> Seq.map Str.toUpper |> print

The one thing that will catch you out: there are no trailing blocks. A lambda is an ordinary argument in parentheses: xs.each { |x| … } becomes xs |> Seq.iter (fun x -> …). For a multi-line body, end the line with (fun x ->, put the body in an indented block below, and close it with ). The block-passing feel survives in retry, poll and within, which each take an indented block directly.

Not here, and what to write instead:

Coming from Perl

Your regex habits carry over well. weir’s Regex pattern matches and extracts in a single match arm. The pattern is compiled at check time, so an invalid regex is a check error, and the number of names you bind must match the number of capture groups.

Perlweir
if (/pat/) on $_if s |> Str.isMatch @"pat" (no $_; you always name the value)
($k) = $l =~ m/(\w+)=/match l with | Regex @"(\w+)=" k -> …
s/old/new/ (literal)Str.replace "old" "new" s (literal text; for regex substitution, use sed)
qx/git status/, backticks$(git status)
@ARGVSelf.args, or Args.load Cli for typed flags
let ver = match "release v42 ready" with | Regex @"v(\d+)" n -> n | _ -> "0"
print $"version {ver}"

Str.rmatch returns an Option, and Str.rmatchAll returns every match (lazily; no matches gives an empty seq). The inline flags (?s) and (?m) turn on DOTALL and MULTILINE. Named groups (?<name>…) are rejected: you name captures in the binder instead (Regex @"(\w+)=(\d+)" (k, v)), so the name sits next to the pattern rather than inside it. Lookbehind ((?<=, (?<!) works.

The one thing that will catch you out: sigils. In Perl, $/@/% mark what kind of variable you have. In weir, $ splices a value into a command line or a string, and ordinary variables have no sigil at all. use strict is always on, with no name.

// a Regex pattern must be a raw string (@"a\+") — an ordinary string
// is rejected here, which is what makes the double-escape footgun
// unrepresentable rather than merely avoidable
let m = match "a+b" with | Regex "a\\+" () -> "hit" | _ -> "miss"
print m

Not here, and what to write instead:

The false friends, collected

The most useful rows on this page: the same spelling with a different meaning.

glyphtherehere
$@Make: the target; bash: all argsspreads a list into arguments: $@xs passes one per element
$xPerl/PowerShell: a variable (sigil for its kind / property access)a splice: one argument, or a hole in a string
`${f}`zx: interpolation, escaped for yougluing a splice into a word is an error; a splice is a whole argument
$()bash: capture as one string, trailing newlines strippedcapture as seq<string>, one element per line
=F#: equalitybinding only; equality is ==
|F#: not a pipe (|> is)sends text to or from an external program; |> is still the function pipe
!bash: history / negationnot used: negation is not, and a command runs just by being written as a statement
//C-family: always a commenta comment only at line start or after whitespace; a standalone // argument needs quotes

With | and |>, what’s on the right decides which one you need. A // inside a word, as in http://a, stays plain text.

What nobody arrives knowing

Everything above is a translation. These are the things no other language prepares you for, each with what it costs you.

let rows = ls |> Seq.length
print $"typed rows: {rows}"
^ls -a

If a section above sent you looking for more: GUIDE.md teaches the language, the reference describes it precisely, and tests/fidelity/divergences.md lists every difference from F#, each checked against the real F# compiler.