Lask Quick Reference
The whole language and CLI on one page, for someone who wants to write a task now and read the rules later. Every section links to the chapter of the language specification that defines it; when this page is not enough, follow the link. Installation is on its own page.
A module, end to end
// main.lask — the default target of every subcommand.
// Environments are values: pin an image once, reuse it everywhere.
go = #golang:1.25
node = #node:20.20.2-alpine3.23
// Declare which image provides each program. A command naming no
// environment is a static error, never a silent fall back to the host.
command { "go" } on go
command { "npm", "npx" } on node
command { "git" } on #local
// Run both suites concurrently, then build.
//
// @param dry_run Report what would happen instead of building.
// @example lask run release --dry-run true
release(--dry_run = false): String = do {
api = async $ go test ./...
web = async $ npm test
await api
await web
if (dry_run) { "would build" } else {
$ go build -o dist/app
"released"
}
}$ lask check # resolve every name, argument and type — nothing runs
$ lask run release # execute
$ lask run release --helpCLI
| Command | What it does |
|---|---|
lask run <fn> [args...] | Execute a task. Writes nothing to stdout. |
lask eval <fn> [args...] | Same, and writes the return value to stdout (JSON by default). |
lask cmd <prog> [args...] | Run a declared command in its declared image, stdio passed through. |
lask repl | Evaluate expressions interactively. :r reloads the module, keeping what was typed. |
lask sync [--frozen] [--prune] | Fetch dependencies, pull and build images and write the lock, showing progress and ending with a table of every module and image; --frozen fails instead of changing anything (CI); --prune removes dependencies no .lask file imports. |
lask deps list | Every dependency, what lask.json requests and the lock pins, and its status: ok, stale, unused, not cached, … |
lask deps graph [name] [--depth n] | The dependency graph from the entry module; with a name, only the paths that reach it. |
lask deps add | rm <name> | Add a dependency (--git <url> --rev <rev> or --url <url>); remove one no module imports. |
lask envs list [fn] | The environments a module uses, the image the lock pins each to, what requires it, and whether it is on the Docker daemon. |
lask secrets list | check [fn] | List the secret references in the environment; check that their stores can be reached and read. |
lask check | Static validation only. Nothing is evaluated, no image is pulled. |
lask serve | Language server (LSP). |
lask completion <shell> | Emit a completion script (bash, zsh, fish). |
lask version | Print the version. |
Options come before the function name — everything after it belongs to the function. --module <path> (default main.lask), --format text\|json, --stdout-encode text\|json\|pretty-json (default json), --arg-decode text\|json\|auto (default auto), --trace-id, --no-color, --confirm. → 11.2
A task's signature is its command line. - maps to _, so release(--dry_run = false) is reachable as lask run release --dry-run true, and show_version as lask run show-version.
A task can ask for a typed confirmation before it runs, declared in lask.json rather than in code. It is a guard against mistakes, not a security boundary. lask check rejects an entry that names no function, parameter or fitting value. → ch. 5, 11.2
{"confirm": {"deploy": {"when": {"env": ["prod"]}}, "destroy": {}, "reset_db": {"phrase": "reset #{db}"}}}At a terminal the phrase is typed; elsewhere --confirm approves, unless LASK_CONFIRM=tty. Refused is exit 4 (E-CLI-NOT-CONFIRMED). Only the function the CLI calls is asked about.
Exit codes: 0 success · 1 syntax or static error · 4 CLI usage error · otherwise the code of the uncaught Error — a failed command passes its own exit code through. → 11.3, 14.8
Declarations
name = "app" // value; evaluated lazily, once, on first use
tag: String = "latest" // with a type annotation
greet(name: String, --prefix = "hi"): String = concat(prefix, name)
type Config = Record<name: String, port: Number>
internal helper() = 1 // not visible outside this module
export public_task() = 2 // the default; the marker just states itOne file is one module. Every top-level declaration is public unless marked internal. A declaration ends at a newline (or ;); it continues across lines after =, ,, :, ?, ->, !, an unclosed bracket, or a binary operator at either end of the break, and before a line-leading ? or : of a conditional.
import { add, Config } from "./lib/math.lask" // named, may rename with `as`
import * as m from "./lib/util.lask" // namespace: m.symbol, m.TypeName
import { send } from "notify" // external dependency, by name
export { rollout } from "./lib/deploy.lask" // re-export, parameter list intactExternal dependencies are declared in lask.json (git + rev, or url) and pinned by content hash in the committed lask.lock.json. check, run, eval and envs never touch the network; lask sync is what fetches and verifies. The import graph must be acyclic, and an import reaches only a dependency's root main.lask. → 11.5, 11.7
Types
| Kind | Types |
|---|---|
| Base | Any Number String Bool Null Void Environment |
| Composite | Array<T> · Map<T> (string keys) · Record<a: T, b?: U> · AsyncHandle<T> · Function<T1, T2, R> (last is the return type) |
| Union | String | Null — the way a value that may be absent is typed |
| Built-in aliases | CommandResult = Record<code: Number, stdout: String, stderr: String> · Error = Record<code: Number, message: String> |
type Strings = Array<String> // alias; may not reference itself
type Opt<A> = A | Null // alias with type parameters
type Config = Record<
name: String, // key required, value a String
port: Number | Null, // key required, value may be null
tags?: Array<String> // key may be absent; reads as T | Null
>
xs: Array<String | Null> = ["a", null]
first_or<T>(xs: Array<T>, fallback: T): T = // a type parameter, never
if (is_empty(xs)) { fallback } else { xs[0] } // written at a call site
n = first_or([1, 2], 0) // instantiated at Number here
largest<T: orderable>(xs: Array<T>): T = last(sort(xs)) // a named bound
label<T: Number | String>(x: T): String = "#{x}" // a type boundAnnotations are optional and inference fills the rest: a parameter with neither an annotation nor a default is Any, and a mixed array literal is Array<Any>. Neither a union nor an optional field is ever inferred — each is only ever something someone wrote.
? qualifies the key and the field's type qualifies the value: a: String | Null must be present and may be null, a?: String may be absent. A type parameter is opaque inside its own declaration — it conforms only to itself and Any — so a body may pass such a value around but not compare, order or interpolate one, unless a bound says so. comparable gives ==, stringifiable gives #{...}, and orderable gives sort and both of the others. A type bound <T: B> admits what conforms to B and gives what B has, though Any gives nothing. Built-ins are bounded the same way (sort<T: orderable>), and a use outside a bound is E-TYPE-BOUND. → 4.2
Conformance is small on purpose. Everything conforms to Any; a member conforms to its union; nothing else does. There is no variance: Array<Number> does not conform to Array<Any>, and Record conforms only when the required set, the optional set and every field type are identical. Getting out of Any or a union takes a runtime check — cast(v) (fails on anything else) or case type dispatch (tests instead of failing). → 4.4
Expressions and operators
→ spec 6.2
! › * / › + - › == != < <= > >= › && || › |> <| >> << › c ? a : b — all left-associative except the conditional, which is right-associative.
c ? a : bisif (c) { a } else { b }: same typing, only the chosen branch runs.a ? x : b ? y : zchains; with more conditions prefercase { ... }.x |> fisf(x);f <| xisf(x).f >> gis\(x) -> g(f(x));f << gis\(x) -> f(g(x)).&&and||short-circuit.- Arithmetic is
Numberonly.+does not concatenate strings — useconcat, or interpolation. - Number literals carry no sign and there is no unary minus: write
0 - 1. ==needs comparable types;Any,FunctionandAsyncHandleare not.
"#{name}:#{tag}" // interpolation, interpreted strings only
'no #{interpolation}' // raw string, verbatim, may span lines
user.name // record field; the field set is fixed, so it cannot fail
xs[0] // array index, zero-based
m["APP_ENV"] // map key, or a field whose name is not an identifier
\(x: Number) -> x + 1 // lambdaRecords have a statically fixed field set and an absent optional key reads as null, so .f never fails at runtime; maps are for keys that vary, and m[k] fails on a missing key (get_or does not). → 6.8
Functions
→ spec 6.1
f(a, b, ...rest: Array<Number>, --opt: String = "x") = [a, b, rest, opt]Positional parameters are required and take no default. Keyword parameters (--name) must have a default and are bound only by name — f(1, opt = "y") in the language, --opt y on the CLI. At most one variadic parameter, right after the positional ones; it collects what is left over, as Array<T>.
Declaration order is enforced: positional, variadic, keyword. f(x) = body is sugar for f = \(x) -> body. Calling through a function value loses the parameter list: positional arguments only, every keyword parameter defaulted.
A declaration may bind type parameters before its parameter list (Types); a default that mentions one must hold for every instantiation, so --xs: Array<T> = [] is legal and --y: T = 1 is not.
Control flow
do { // sequence; the value is that of the last statement
a = compute() // a!!: String = ... marks a secret
if (a == "") { fail(error(2, "empty")) } // stops here; there is no return
log(a)
"done"
}
if (c) { x } else { y } // an expression: both branches have one type
c ? x : y // the same, for one-expression branches
if (c) { x } // no else: x or null, so T | Null (Void if x is)
case (shell) { // value dispatch, scrutinee evaluated once
"bash", "zsh" -> "posix"
"fish" -> "fish"
else -> fail(error(4, "unknown shell"))
}
case (v) { // type dispatch over a union or Any, with narrowing
Null -> "(none)"
Number -> to_string(v) // v: Number here
else -> v // v: String here, the only member left
}
case { // condition form, in place of a chained `else if`
n >= 500 -> "error"
n >= 400 -> "warn"
else -> "info"
}
for (x : xs) { concat("item:", x) } // an Array<R>; Void body becomes for_eachExactly one else arm, last, always — exhaustiveness is never inferred. An arm body is an expression: use do { ... } for several statements, and note that { right after -> is an object literal. for iterates arrays only: count with range(0, n), index with enumerate(xs), traverse a map with entries(m).
Narrowing is a property of case alone, and only when the scrutinee is written as a plain local name. if (x == null) { ... } narrows nothing.
Commands and environments
$ go build // String: stdout; fails (with the command's exit code) if code != 0
$1 go build // same as $
$2 go build // String: stderr, same failure rule
$* go build // CommandResult {code, stdout, stderr} — never fails on exit code
$[#alpine:3.20] uname -a // explicit environment
$[env] sh -lc 'echo hi' // any expression of type EnvironmentA command string runs to the end of the line — nothing of the enclosing expression may follow it on that line. End the line with \ to continue it, and put |> trim on the next line to keep piping. A command in the ? branch of a conditional likewise needs the : ... on the next line. #{e} interpolates a stringifiable value (String | Null is not one: resolve it first).
#local // the host
#alpine:3.20 // an image in a registry
#ubuntu@sha256:9cee... // a digest works too
#./infra/Dockerfile(context = ".", build_args = {"V": "1"}) // built from a recipe
#node:20-alpine(platform = "linux/amd64") // one platform's variant
#alpine:3.20{memory: "4g"} // run options in braces: a Runnable
#node:20-alpine{env: {"CI": "1"}, network: "none", read_only: true}
runnable(e, memory = "8g") // the same for an Environment value eThe head is the only place an image is named — no string or variable can name one — so every image a program uses is found by reading its source, and lask sync pins each. A registry reference may leave out its tag — a bare name means latest — since the lock pins the digest it resolved to, and runs use that. A recipe path starts with . and is relative to the module's directory, inside its own tree.
Image options in parentheses decide which image is used: platform, and context and build_args on a recipe. They are literals. Run options in braces decide how it runs, and take any expression: memory, memory_swap, memory_reservation, cpus, cpu_shares, cpuset_cpus, cpuset_mems, pids_limit, shm_size, blkio_weight, ulimits; workdir, user, env, hostname, init; read_only, tmpfs, cap_drop; network, dns, dns_search, add_hosts, publish; volumes. Environment variable names are not lower_id, so quote them: env: {"CI": "1"}. An option given null is left out, as are the null elements of a list or table — "" is a value and is passed on. → 10.2
#head{...} is sugar for runnable(#head, ...), which turns an Environment into a Runnable. An Environment is a Runnable with no options, so $[...], run and command ... on take either. runnable takes an Environment only: options are given once. A function can take the image as --image: Environment = #python:3.12 and return runnable(image, env = {...}), so a caller picks the version with python(image = #python:3.13). → 6.7, 15.5
Dispatch. A $ with no [env] gets its environment from the command words in the string, matched against the command words the module declares or imports. → 10.9
command { "go" } on #golang:1.25
command { "node", "npm", "npx" } on node
command { "aws" } on tools.aws(profile = "dev") // any Environment expression
internal command { "ls" } on #local // not importable elsewhere
import command { "python", "pip" } from "tools" // words another module exports
export command { "helm" } from "./lib/k8s.lask" // import, and export againMatching is lexical and exact: cd web && npm ci selects the Node image, FOO=1 npm ci still does, and /usr/bin/npm does not (never a basename). Selection is unanimity — ls dist && npm publish is E-TYPE-COMMAND-CONFLICT. Two words agree when their declarations name the same literal environment or the same binding; two separate calls do not, so declare such words together. Nothing matched is E-TYPE-COMMAND-NOENV, not a fall back to the host. A command word arrives only by import command, never by a named or namespace import.
A declaration's environment is evaluated when a command runs, and may not have effects — no command, file, stdin, log or random value, directly or through what it calls (E-TYPE-COMMAND-EFFECT); get_env is fine. → 5
Images are materialized only by lask sync; run and eval never pull or build, and a missing image is E-IO-IMAGE-MISSING naming the command that would fix it. → 10.3
Concurrency
→ spec 6.3
a = async test_api() // starts now, returns AsyncHandle<T>
b = async test_web()
r = { api: await a, web: await b } // await joins; a failure inside is raised hereasync e is spawn(\() -> e). Awaiting the same handle twice gives the same result. all(handles) waits for every one and, if one fails, cancels the rest; race(handles) waits for the first and cancels the rest.
Errors
command { "make", "rm" } on #local
build(): String = try {
$ make build
} catch (e) { // e: Error = {code, message}
if (e.code == 2) {
$ make clean
$ make build
} else {
fail(e)
}
} finally {
$ rm -rf ./tmp // runs exactly once, on both paths
}fail(error(4, "unknown shell")) raises. Body and catch must have the same type; finally's value is discarded. Static errors are found before evaluation and are never catchable. A failure inside catch or finally propagates outward.
Retrying and waiting
command { "make", "curl" } on #local
build(): String = $ make build
probe(): CommandResult = $* curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/
a(): String = retry(backoff_exponential(1, 2, 3), \() -> build()) // waits 1, 2, 4; then re-raises the last failure
b(): String = retry_if([5, 5], \(e: Error) -> e.code == 75, \() -> build()) // retries only code 75
c(): String = timeout(60, \() -> build()) // fails with code 124 after 60s
d(): CommandResult = until(backoff_fixed(2, 30), \(r: CommandResult) -> r.stdout == "200", \() -> probe())A strategy is the array of delays, so its length bounds the attempts: backoff_fixed(2, 3) is [2, 2, 2], backoff_linear(1, 2, 3) is [1, 3, 5], backoff_jitter(ds) randomizes each into [0, d], and a cap is map(ds, \(d) -> min(d, 30)). timeout stops the body's commands and raises at the call, so a try inside the body never sees it. until gives up with code 124 too. A command runs to the end of its line, so wrap one in a function before passing it.
Input, output, secrets
→ spec ch. 9, 6.10
stdin is a reserved identifier bound once per run, always String. Structure it in the language: lines(stdin), from_json(stdin), then cast or case.
run writes nothing to stdout, eval writes the return value there, and logs and diagnostics always go to stderr. That is what makes lask eval a | lask run b work. → 11.3
deploy(--key!!: String = get_env("AWS_SECRET_ACCESS_KEY")) = do {
$[#amazon/aws-cli:2.31.9] aws configure set aws_secret_access_key #{key}
}!! marks a binding secret: the value is masked in the command execution log wherever it later appears. It is allowed on String and String | Null — a null registers nothing, so a secret can default to null like any optional parameter — carries no meaning in the type system, and never masks a CommandResult or eval's own output. → 12.8
A variable whose whole value is a secret reference is resolved when get_env, find_env or get_env_or reads it, and the value is masked like a !! one. The program stays the same; only the environment changes. LASK_SECRETS lists the stores a run may use. lask secrets check [fn] tells you whether they can be reached and read before a task runs. → 9.8, 11.10
export LASK_SECRETS=vault VAULT_ADDR=https://vault.example.com VAULT_TOKEN=...
export AWS_SECRET_ACCESS_KEY="{vault://secret/aws#secret_key}" # {vault://<path>[?version=n]#<field>}Documentation comments
The unbroken run of comments directly above a declaration is its documentation, and it is the single source for both --help and the editor's hover.
// Build and publish the image. <- summary (first paragraph)
//
// Longer description here.
//
// @param tag The tag to publish.
// @return The digest that was pushed.
// @example lask run publish --tag v1.2.0
// @complete tag @keys known_tags
publish(--tag: String = "latest"): String =
$[#docker:27.3.1-cli] docker push app:#{tag}@hidden keeps a declaration out of listings while leaving help by name working. @complete <param> steers shell completion, as @keys <map binding>, @file [*.ext], @dir, or a literal list of choices. Unknown tags are carried through rather than rejected, and @param matches after the -/_ mapping, so out_dir and out-dir name the same parameter.
Built-in library
→ spec ch. 15. No overloading: one name, one signature.
| Group | Functions |
|---|---|
| Numbers | add sub mul div mod abs floor ceil round min max sum pow sqrt clamp |
| Strings | length concat trim to_lower to_upper split join replace contains starts_with ends_with index_of substring pad_start pad_end repeat lines to_string to_number regex_test regex_match regex_replace |
| Arrays | map filter reduce for_each append concat_array size is_empty first last slice take drop reverse sort sort_by contains_array index_of_array find find_index every any flatten flat_map zip unique range enumerate |
| Maps | get get_or has_key keys values set remove merge entries from_entries map_values |
| Commands | run shell_quote |
| Async | spawn await all race |
| Errors | recover fail error retry retry_if until timeout backoff_fixed backoff_linear backoff_exponential |
| Data | to_json from_json encode decode cast base64_encode base64_decode sha256 md5 |
| Environment | get_env find_env has_env get_env_or mark_secret |
| Paths | path_join dirname basename extname normalize_path is_absolute_path |
| Filesystem | read_file write_file file_exists remove_file make_dir list_dir glob — each takes the Environment as its last argument |
| Other | log uuid random_string backoff_jitter |
Absence is reported two ways, deliberately: a function that returns a position reports it as -1 (index_of, find_index), and one that returns a value reports it as Null (find, find_env).
Things that catch people out
+is arithmetic only. Strings join withconcator#{...}.- There is no unary minus.
0 - 1. - There is no
return. A block's value is its last statement: stop withif (bad) { fail(...) }, and choose withif/else,? :orcase. ifwithoutelseisnullwhen its condition is false, so its value isT | Null— which#{...}does not take. Give it anelsewhere a value is needed.- Every
caseneeds anelse, and it must be last. - A command with no environment is an error, never the host.
#localis something you write. - A command string swallows the rest of its line, closing brackets included, so a
$command cannot sit inside a call's parentheses. Bind it to a name on its own line first. - A command runs as
/bin/sh -c <string>in its container, with the project directory mounted at/workas the working directory. An image without/bin/shcannot run a command. lask runwrites nothing to stdout: the return value is not printed, and the command log goes to stderr. To see the value, uselask eval.- A
Boolparameter takes a value on the command line:--verbose true, never a bare--verbose. - A task that refers to
stdinreads it to the end before it starts. Started from a script that leaves stdin open, it waits; give it</dev/null. A task that never refers tostdindoes not read it. - Conformance is invariant:
Array<Number>is not anArray<Any>. - Keyword parameters must have defaults; positional parameters must not.
fortakes an array, not a number and not a map.- Interpolating a
String | Nullis a type error — resolve it withcasefirst. a?: Tanda: T | Nullare different questions: the first is about the key, the second about the value.- A type parameter is opaque inside its own body: bound it (
<T: comparable>), or take the operation you need on aTas an argument.
Where to look next
| Question | Spec |
|---|---|
| What exactly is the grammar? | ch. 3, ch. 6 |
| When does this type check? | ch. 4, ch. 7 |
| What order does this evaluate in? | ch. 8 |
| How are images resolved and pinned? | ch. 10 |
| What does the CLI guarantee? | ch. 11 |
| What appears in the log, and what is masked? | ch. 12 |
| What is this error code? | ch. 14 |
| Show me more complete programs. | ch. 16, example/ |
Moving an existing project over? Migrating from Make and Migrating from GitHub Actions map what you have onto the sections above, one task at a time.