docs RestermScript
Language
Comments, literals, operators, types and error handling.
Comments
# starts a comment that runs to the end of the line. It can appear after whitespace or code.
Blocks and statement endings
Blocks use { ... } and group statements together. A newline can end a statement when the previous token can finish a statement. Newlines inside () and [] are ignored. The language also accepts the semicolon token as a statement terminator, but this guide uses newlines for clarity.
Identifiers and keywords
Identifiers start with a letter or _ and can contain letters, digits, and _ characters. The language reserves keywords and they cannot be used as identifiers.
Keywords:
export module fn let const if elif else switch case default try return for break continue range
true false null and or notA reserved word cannot be a bare name anywhere, which includes dict keys and field access. Data that carries a key spelled like a keyword uses the quoted forms instead:
let cfg = {"default": 1}
let v = cfg["default"]Literals
null
true / false
123 3.14
"string" 'string'
[1, 2, 3]
{a: 1, "b": 2}String escapes include \n, \r, \t, \\, \", and \'. Dict keys in literals are identifiers or quoted strings, and dict keys are always strings at runtime.
Operators by precedence
Listed from tightest to loosest. Each level binds more tightly than the one below it.
- Postfix: function calls, indexing, and member access.
- Unary:
notor!,try, and unary-. - Multiplicative:
*,/, and%. - Additive:
+and-. - Comparison:
<,<=,>,>=,in, andnot in. - Equality:
==and!=. - Logical AND:
andor&&. - Logical OR:
oror||. - Coalescing:
??returns the right side when the left side is null. - Ternary:
cond ? a : bselects between two values.
?? sits near the bottom, so it binds looser than arithmetic, comparison, and both logical operators. a ?? b + c means a ?? (b + c), and a ?? b or c means a ?? (b or c). Parenthesise when you want the other grouping.
+ adds numbers or concatenates strings. Non numeric values are converted to string using str(). Ordering comparisons only work for numbers or strings, and equality only works for primitive types.
Membership operators
value in container is the infix form of contains(container, value). value not in container applies the same membership test and negates its result:
response.statusCode in [200, 201, 204]
"json" in response.header("Content-Type")
"request_id" in response.json()
response.statusCode not in [400, 404, 500]The container determines how membership works:
- A list compares each item to the value with RestermScript equality. Kinds must match, primitive values compare by value, and lists, dicts, functions, and objects are never deeply equal.
- A string converts the value with
str()and tests for a substring. The empty string is contained in every string. - A dict converts the value with
str()and checks for an exact, case-sensitive key.nullconverts to the empty string, sonull in dictasks for a""key. - Any other container is an evaluation error.
Host bindings such as response, vars, and env are objects, not containers, so they take the error branch instead of testing membership. Use the accessor each one already provides: vars.has("token") rather than "token" in vars, and "request_id" in response.json() rather than "request_id" in response.
not in is one comparison operator. Because unary not binds more tightly than every binary operator, write value not in container, not not value in container, when you want to negate membership. Like the existing ordering operators, membership is left-associative; comparisons are not rewritten into chained tests.
in is contextual rather than reserved. It remains valid as a binding, function or module name, member, and dict key when it is not between two expressions:
let in = {in: true}
in.inIn .rts modules and @rts blocks, a line may end after either membership operator:
let allowed = code in
[200, 201, 204]Request-file directives retain their delimiter-based multiline rule. Open a group when a directive needs to span lines:
# @assert (
# response.statusCode in
# [200, 201, 204]
# )Logical operators
RestermScript supports word and symbolic forms for its logical operators: and or &&, or or ||, and not or !. Each pair is interchangeable and has the same precedence and short-circuit behavior:
if r.ok && !r.retry { return r.value }
if r.ok and not r.retry { return r.value }Logical AND and OR always return a bool, not one of their operands. For example, 1 && 2 evaluates to true, not 2.
Like not, ! binds more tightly than any binary operator. This means !a == b is parsed as (!a) == b. To negate the equality expression instead, write !(a == b) or simply a != b.
When a line ends with && or ||, the expression continues on the next line:
let ready = r.ok &&
r.value.count > 0RestermScript does not have bitwise operators, so a single & or | is a parse error.
Fallback values with ??
?? is the way to supply a fallback. It returns the left side unless it is null, and it is lazy: the right side is only evaluated when the left side is null.
let token = vars.get("auth.token") ?? env.get("auth.token")
let label = candidate ?? "unknown"Laziness means the fallback can be expensive or failing without cost when it is not needed:
"ok" ?? fail("boom") # "ok", fail is never called?? reacts to null only. false, 0, "", the empty list, and the empty dict are real values and pass straight through:
0 ?? 5 # 0
"" ?? "x" # ""When you do want any falsey value replaced, that is a different question and the ternary answers it:
let value = candidate ? candidate : "something"?? does not rescue an undefined name. missingName ?? "fallback" is still an error, which keeps typos visible. Optional lookups return null explicitly instead, so vars.get("missing") ?? "fallback" works.
Migrating from default()
default(a, b), rts.default(a, b), and stdlib.default(a, b) were removed, and default became a reserved word. Replace every call with (a ?? b):
default(vars.get("token"), "anon") # removed
(vars.get("token") ?? "anon") # replacementKeep the parentheses. A call is a single tight unit, but ?? binds looser than every operator except the ternary, so dropping them regroups the expression whenever the call was part of a larger one:
default(a, b) + c # old, means (a ?? b) + c
a ?? b + c # wrong, parses as a ?? (b + c)
(a ?? b) + c # rightThe parentheses are only redundant when the call was the entire expression, as in the vars.get example above.
The replacement is not only shorter. default(a, b) was an ordinary call, so b was evaluated before the call ran, whether or not a was null. ?? evaluates b only when a is null. If a fallback did real work, that work now happens only when it is actually needed:
default(a, fail("missing")) # always failed
a ?? fail("missing") # fails only when a is nullCheck fallbacks that call uuid(), mutate vars, or fail. Migrating them changes when they run, not just how they are spelled.
Because default is reserved, these spellings are now parse errors: let default = 1, fn default() {}, {default: 1}, and value.default. Dict data that genuinely has a default key uses {"default": 1} and value["default"].
Error handling with try
try exprThe try operator evaluates its expression and returns an object with ok, value, and error fields. ok is true on success and false on error. value holds the result on success and is null on error. error is a single line error string on failure and null on success. It does not catch hard aborts such as step limits, timeouts, or cancellations. You can use try expr directly in conditionals, but checking r.ok is often clearer.
Example:
let r = try json.file("_data/users.json")
if not r.ok { return [] }
return r.valueUse in .http expressions and directives:
# @when try json.file("_data/flags.json")
# @for-each ((try json.file("_data/users.json")).value ?? []) as user
# @assert try response.json("data")
Authorization: Bearer {{= (try last.json("auth.token")).value ?? "" }}This pattern is most useful for optional files, optional JSON bodies, or helper calls that may fail.
Types and truthiness
RTS has several runtime types.
- Null represents the absence of a value.
- Bool represents true or false.
- Number uses float64 for numeric values.
- String stores UTF 8 text.
- List stores ordered values.
- Dict stores key value pairs.
- Function represents a callable value.
- Object represents host objects provided by Resterm.
Truthiness follows consistent rules. Null, false, zero, the empty string, the empty list, and the empty dict are false. All other values are true unless a host object defines custom truthiness (for example, try results are truthy only when ok is true).
Indexing and member access
List indexing uses numeric indices such as list[0], and out of range accesses return null. Dict access uses dict["key"] or dict.key, and missing keys return null. Object member access is supported, while indexing depends on the object implementation.
Keys and names
Dictionary and query keys are exact strings. Case and whitespace are preserved, including empty query keys. The rts.dict helpers behave like dict[key], so Token, token, and token are separate keys.
Names used by env, vars, and request headers have different rules. Resterm makes env and vars names case-insensitive and ignores surrounding whitespace. Header names are case-insensitive HTTP field names. A name your script supplies is a value, so whitespace around it is rejected instead of trimmed. The header block of a request file is syntax rather than a value, so the parser trims around the colon and then holds what is left to the same rule.
Host maps are validated before evaluation. Blank env or vars names, and two spellings with the same identity, are errors instead of choices made by map order. Header blocks likewise reject invalid field names and equivalent spellings.
What happens to a name a rule does not accept depends on where it came from. A name your script writes is your own word, and a header name that is not an HTTP field name asks a question no request can answer, so it is reported:
request.header("X Token") // error, not an HTTP field name
headers.get({"X-Ok": "yes"}, "X Tok") // error, same rule
headers.set(h, " X-Token ", "1") // error, whitespace is not trimmedA malformed host map or header block fails as one value; helpers never silently discard an entry. This keeps all evaluations deterministic and makes bad input visible at the boundary:
env.get(" ") // error
headers.get({"X Token": "a", "X-Ok": "yes"}, "X-Ok") // errorHeader names are checked when the file is parsed, so a header whose name is not an HTTP field name is reported against its line before anything runs. Runtime construction and dispatch also reject it; invalid names are never exposed through request.headers.