Skip to content

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 ​

lask
// 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"
  }
}
bash
$ lask check            # resolve every name, argument and type — nothing runs
$ lask run release      # execute
$ lask run release --help

CLI ​

→ spec ch. 11

CommandWhat 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 replEvaluate 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 listEvery 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 checkStatic validation only. Nothing is evaluated, no image is pulled.
lask serveLanguage server (LSP).
lask completion <shell>Emit a completion script (bash, zsh, fish).
lask versionPrint 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

json
{"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 ​

→ spec ch. 5

lask
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 it

One 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.

lask
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 intact

External 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 ​

→ spec ch. 4

KindTypes
BaseAny Number String Bool Null Void Environment
CompositeArray<T> · Map<T> (string keys) · Record<a: T, b?: U> · AsyncHandle<T> · Function<T1, T2, R> (last is the return type)
UnionString | Null — the way a value that may be absent is typed
Built-in aliasesCommandResult = Record<code: Number, stdout: String, stderr: String> · Error = Record<code: Number, message: String>
lask
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 bound

Annotations 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 : b is if (c) { a } else { b }: same typing, only the chosen branch runs. a ? x : b ? y : z chains; with more conditions prefer case { ... }.
  • x |> f is f(x); f <| x is f(x).
  • f >> g is \(x) -> g(f(x)); f << g is \(x) -> f(g(x)).
  • && and || short-circuit.
  • Arithmetic is Number only. + does not concatenate strings — use concat, or interpolation.
  • Number literals carry no sign and there is no unary minus: write 0 - 1.
  • == needs comparable types; Any, Function and AsyncHandle are not.
lask
"#{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   // lambda

Records 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

lask
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 ​

→ spec 6.4, 6.5

lask
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_each

Exactly 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 ​

→ spec 6.6, 6.7, ch. 10

lask
$  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 Environment

A 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).

lask
#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 e

The 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

lask
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 again

Matching 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

lask
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 here

async 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 ​

→ spec 6.9, ch. 14

lask
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 ​

→ spec 15.7

lask
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

lask
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

bash
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 ​

→ spec 3.1, 11.6

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.

lask
// 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.

GroupFunctions
Numbersadd sub mul div mod abs floor ceil round min max sum pow sqrt clamp
Stringslength 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
Arraysmap 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
Mapsget get_or has_key keys values set remove merge entries from_entries map_values
Commandsrun shell_quote
Asyncspawn await all race
Errorsrecover fail error retry retry_if until timeout backoff_fixed backoff_linear backoff_exponential
Datato_json from_json encode decode cast base64_encode base64_decode sha256 md5
Environmentget_env find_env has_env get_env_or mark_secret
Pathspath_join dirname basename extname normalize_path is_absolute_path
Filesystemread_file write_file file_exists remove_file make_dir list_dir glob — each takes the Environment as its last argument
Otherlog 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 with concat or #{...}.
  • There is no unary minus. 0 - 1.
  • There is no return. A block's value is its last statement: stop with if (bad) { fail(...) }, and choose with if/else, ? : or case.
  • if without else is null when its condition is false, so its value is T | Null — which #{...} does not take. Give it an else where a value is needed.
  • Every case needs an else, and it must be last.
  • A command with no environment is an error, never the host. #local is 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 /work as the working directory. An image without /bin/sh cannot run a command.
  • lask run writes nothing to stdout: the return value is not printed, and the command log goes to stderr. To see the value, use lask eval.
  • A Bool parameter takes a value on the command line: --verbose true, never a bare --verbose.
  • A task that refers to stdin reads 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 to stdin does not read it.
  • Conformance is invariant: Array<Number> is not an Array<Any>.
  • Keyword parameters must have defaults; positional parameters must not.
  • for takes an array, not a number and not a map.
  • Interpolating a String | Null is a type error — resolve it with case first.
  • a?: T and a: T | Null are 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 a T as an argument.

Where to look next ​

QuestionSpec
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.

Released under the MIT License.