Bindings and functions
Bindings
A binding assigns a name to a value. Bindings are immutable — every name is assigned exactly once:
host = `api.example.com`
port = 8080
config = { host, port }
Bindings may carry an explicit type annotation:
port: number = 8080
Bindings can reference other bindings freely. The compiler builds a dependency graph and evaluates everything in the correct order:
base_port = 3000
api_port = base_port + 1
db_port = base_port + 2
Entrypoint
Every Rank program exports a zero-argument function named pub main. Its return value is what gets emitted:
config = {
host: `localhost`,
port: 8080,
}
pub main = || config
|| is the zero-argument function syntax. pub marks the binding as exported.
Functions
Functions are first-class values. They can be bound to names, passed as arguments, and returned:
increment = |x| x + 1
add = |left, right| left + right
Functions with a block body use return to yield their result:
clamp = |value, min, max| {
clamped = value < min ? min : (value > max ? max : value)
return clamped
}
Typed block-bodied functions declare parameter and return types:
normalize = |port: number, max: number| -> number {
ratio = port / max
return ratio
}
Functions are compile-time values. They are used during evaluation but cannot appear in the final emitted output.
Destructuring
Object fields can be destructured directly into bindings:
response = { status: 200, body: `ok` }
{ status, body } = response
This is shallow sugar: each identifier reads the same-named field from the source object.
Annotations can also attach to a destructuring declaration, but their behavior depends on the annotation:
@constraintreceives the full source object asself@uniqueregisters each extracted field independently@sensitivemarks every extracted binding as sensitive
For example, a destructuring constraint can validate a relationship between fields on the source object:
@constraint(cond: |self| self.api_port != self.db_port)
{ api_port, db_port } = config
Annotations
At declaration level, annotations attach compile-time invariants or diagnostic metadata to the immediately following declaration.
@constraint runs a boolean check at compile time:
@constraint(cond: |self| self >= 1 && self <= 65535)
port = 8080
@unique enforces uniqueness across all bindings in the same named group within the program:
@unique(group: ports)
api_port = 8080
@unique(group: ports)
db_port = 5432
@unique(group: ports)
metrics_port = 8080 // compile error: 8080 is already in the `ports` group
On a destructuring declaration, @unique checks each extracted field independently against the group.
@sensitive marks a binding as redacted in diagnostics and traces:
@sensitive
secret_key = env.SECRET_KEY
On a destructuring declaration, all extracted bindings become sensitive:
credentials = {
access_key_id: `AKIA...`,
secret_access_key: `...`,
}
@sensitive
{ access_key_id, secret_access_key } = credentials
Ordinary object-type fields can also carry field-level @constraint, @unique, and @sensitive annotations:
ServiceConfig = Object {
@unique(group: service_ids)
id: string,
@constraint(cond: |self| self >= 1 && self <= 65535)
port: number,
@sensitive
token?: string,
}
Field annotations differ from declaration annotations:
selfinside a field-level@constraintis the field value, not the containing object.- They are valid on ordinary fields in
Object { ... }and refinement bodies. - They are enforced whenever a concrete value is checked against the annotated type.
- Utility-type views such as
Pick,Partial, andRequiredpreserve them when the field survives. - Mapped bodies and rest fields do not accept field annotations in the current language surface.
Doc comments use stacked /// lines and attach to the immediately following declaration:
/// The port the HTTP API listens on.
/// Must be in the unprivileged range.
@constraint(cond: |self| self >= 1024 && self <= 65535)
api_port = 8080
Doc comments are preserved in YAML and JSONC output.
Operators
Arithmetic
+, -, *, /, % operate on numbers. + also concatenates strings:
sum = 1 + 2
label = `port-` + `8080`
Comparison and equality
<, <=, >, >= operate on numbers. == and != use deep structural equality:
same = { a: 1 } == { a: 1 } // true
Comparison and equality operators are non-chainable.
Boolean
&&, ||, ! are boolean operators:
valid = port >= 1024 && port <= 65535
Null defaulting
?? returns the left side unless it is null, then evaluates and returns the right:
value = maybe_null ?? `default`
Ternary
condition ? then_value : else_value — evaluates only the selected branch:
label = is_prod ? `production` : `development`
Object and list composition
with performs a deep recursive merge. Right-hand fields win on conflict:
base = { host: `localhost`, port: 80, tls: false }
override = base with { port: 443, tls: true }
// { host: `localhost`, port: 443, tls: true }
++ composes two lists or two disjoint objects:
all_ports = [80, 443] ++ [8080, 8443]
merged = { a: 1 } ++ { b: 2 } // keys must not overlap
Pipeline
|> passes the left value as the first argument to the right function. Useful for chaining transformations:
use std::collections::{ map, filter }
result = [1, 2, 3, 4, 5]
|> filter(|x| x > 2)
|> map(|x| x * 10)
// [30, 40, 50]
Pattern matching
match is Rank's general pattern-matching form. It is how the language branches on literals, types, object shapes, and structured strings.
Literal and type patterns
match dispatches on a value using literal patterns and type patterns:
Mode = `dev` | `prod`
mode: Mode = `dev`
label = match mode {
`dev` => `development`,
`prod` => `production`,
}
Named type patterns narrow the scrutinee type inside the arm:
describe = |value: string | number| match value {
string => `it is a string`,
number => `it is a number`,
_ => `unknown`,
}
Object patterns
Named type arms can also destructure object-shaped variants and bind fields for that arm only:
Circle = Object {
radius: number,
color: string,
}
Square = Object {
side: number,
color?: string,
}
Shape = Circle | Square
measure = |shape: Shape| -> number {
return match shape {
Circle { radius } => radius,
Square { side } => side,
}
}
parse string patterns
Structured string patterns can bind pieces of a string with parse templates:
sumVersionParts = |version_string: string| -> number {
return match version_string {
parse`{major:number}-{minor:number}-{patch:number}` => major + minor + patch,
_ => 0,
}
}
This is the pattern-matching sibling of parse template types. In a type alias, parse constrains which strings are valid. In a match arm, the same syntax binds decoded captures like major, minor, and patch.
The same surface works for irregular structured text too, not just version strings:
readAccessStatus = |line: string| -> string | null {
return match line {
parse`{clientIp:string} - {remoteUser:string} [{timestamp:string}] "{method:string} {target:string} HTTP/{protocol:string}" {status:string} {bytesSent:string} "{referrer:string}" "{userAgent:string}"` => status,
_ => null,
}
}
See the S3 Logs example for a complete server that parses nginx access lines this way.
Scope and exhaustiveness
Bindings introduced by destructuring or parse patterns exist only inside the arm where they are declared.
_ is the wildcard arm. Match is exhaustive — if the compiler can prove the arms do not cover all possible values, it reports an error, and if it cannot prove exhaustiveness you should include _.
Pattern matching is not an isolated feature. The same structured-text and matching ideas also show up in route definitions such as ``path: match/users/{id:UserId}``` and in parse` template types used elsewhere in the language.
Automatic lifting
When a function is applied to a list or object, Rank automatically distributes the call across the elements:
increment = |x: number| x + 1
result = increment([1, 2, 3]) // [2, 3, 4]
keyed = increment({ a: 1, b: 2 }) // { a: 2, b: 3 }
When both arguments are lists of the same length, the function is applied element-wise (zipped):
add = |left: number, right: number| left + right
sums = add([1, 2, 3], [10, 20, 30]) // [11, 22, 33]
Lifting is shape-based and applies only to lists and objects. null is not a collection shape — functions that receive null through lifting must handle it explicitly.