Skip to main content

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:

  • @constraint receives the full source object as self
  • @unique registers each extracted field independently
  • @sensitive marks 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:

  • self inside a field-level @constraint is 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, and Required preserve 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.