Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Language basics

This guide covers the core syntax and semantics of Harn.

Implicit pipeline

Harn files can contain top-level code without a pipeline block. The runtime wraps it in an implicit pipeline automatically:

let x = 1 + 2
println(x)

fn double(n) {
  return n * 2
}
println(double(5))

This is convenient for scripts, experiments, and small programs.

Pipelines

For larger programs, organize code into named pipelines. The runtime executes the pipeline named default, or the first one declared.

pipeline default(task) {
  println("Hello from the default pipeline")
}

pipeline other(task) {
  println("This only runs if called or if there's no default")
}

Pipeline parameters task and project are injected by the host runtime. A context dict with keys task, project_root, and task_type is always available.

Variables

let creates immutable bindings. var creates mutable ones.

let name = "Alice"
var counter = 0

counter = counter + 1  // ok
name = "Bob"           // error: immutable assignment

Bindings are lexically scoped. Each if branch, loop body, catch body, and explicit { ... } block gets its own scope, so inner bindings can shadow outer names without colliding:

let status = "outer"

if true {
  let status = "inner"
  println(status)  // inner
}

println(status)    // outer

If you want to update an outer binding from inside a block, declare it with var outside the block and assign to it inside the branch or loop body.

Types and values

Harn is dynamically typed with optional type annotations.

TypeExampleNotes
int42Platform-width integer
float3.14Double-precision
string"hello"UTF-8, supports interpolation
booltrue, false
nilnilNull value
list[1, 2, 3]Heterogeneous, ordered
dict{name: "Alice"}String-keyed map
closure{ x -> x + 1 }First-class function
duration5s, 100msTime duration

Type annotations

Annotations are optional and checked at compile time:

let x: int = 42
let name: string = "hello"
let nums: list<int> = [1, 2, 3]

fn add(a: int, b: int) -> int {
  return a + b
}

Supported type expressions: int, float, string, bool, nil, list, list<T>, dict, dict<K, V>, union types (string | nil), and structural shape types ({name: string, age: int}).

Parameter type annotations for primitive types (int, float, string, bool, list, dict, set, nil, closure) are enforced at runtime. Calling a function with the wrong type produces a TypeError:

fn add(a: int, b: int) -> int {
  return a + b
}

add("hello", "world")
// TypeError: parameter 'a' expected int, got string (hello)

Structural types (shapes)

Shape types describe the expected fields of a dict. The type checker verifies that required fields are present with compatible types. Extra fields are allowed (width subtyping).

let user: {name: string, age: int} = {name: "Alice", age: 30}
let config: {host: string, port?: int} = {host: "localhost"}

fn greet(u: {name: string}) -> string {
  return "hi ${u["name"]}"
}
greet({name: "Bob", age: 25})

Use type aliases for reusable shape definitions:

type Config = {model: string, max_tokens: int}
let cfg: Config = {model: "gpt-4", max_tokens: 100}

Truthiness

These values are falsy: false, nil, 0, 0.0, "", [], {}. Everything else is truthy.

Strings

Interpolation

let name = "world"
println("Hello, ${name}!")
println("2 + 2 = ${2 + 2}")

Any expression works inside ${}.

Raw strings

Raw strings use the r"..." prefix. No escape processing or interpolation is performed – backslashes and dollar signs are taken literally. Useful for regex patterns and file paths:

let pattern = r"\d+\.\d+"
let path = r"C:\Users\alice\docs"

Raw strings cannot span multiple lines.

Multi-line strings

let doc = """
  This is a multi-line string.
  Common leading whitespace is stripped.
"""

Multi-line strings support ${expression} interpolation with automatic indent stripping:

let name = "world"
let greeting = """
  Hello, ${name}!
  Welcome to Harn.
"""

Escape sequences

\n (newline), \t (tab), \\ (backslash), \" (quote), \$ (dollar sign).

String methods

"hello".count                    // 5
"hello".empty                    // false
"hello".contains("ell")          // true
"hello".replace("l", "r")       // "herro"
"a,b,c".split(",")              // ["a", "b", "c"]
"  hello  ".trim()              // "hello"
"hello".starts_with("he")       // true
"hello".ends_with("lo")         // true
"hello".uppercase()             // "HELLO"
"hello".lowercase()             // "hello"
"hello world".substring(0, 5)   // "hello"

Operators

Ordered by precedence (lowest to highest):

PrecedenceOperatorsDescription
1|>Pipe
2? :Ternary conditional
3??Nil coalescing
4||Logical OR (short-circuit)
5&&Logical AND (short-circuit)
6== !=Equality
7< > <= >= in not inComparison, membership
8+ -Add, subtract, string/list concat
9* /Multiply, divide
10! -Unary not, negate
11. ?. [] [:] () ?Member access, optional chaining, subscript, slice, call, try

Division by zero returns nil. Integer division truncates. Arithmetic operators are strictly typed — mismatched operands (e.g. "hello" + 5) produce a TypeError. Use to_string() or string interpolation ("value=${x}") for explicit conversion.

Optional chaining (?.)

Access properties or call methods on values that might be nil. Returns nil instead of erroring when the receiver is nil:

let user = nil
println(user?.name)           // nil (no error)
println(user?.greet("hi"))    // nil (method not called)

let d = {name: "Alice"}
println(d?.name)              // Alice

Chains propagate nil: a?.b?.c returns nil if any step is nil.

List and string slicing ([start:end])

Extract sublists or substrings using slice syntax:

let items = [10, 20, 30, 40, 50]
println(items[1:3])   // [20, 30]
println(items[:2])    // [10, 20]
println(items[3:])    // [40, 50]
println(items[-2:])   // [40, 50]

let s = "hello world"
println(s[0:5])       // hello
println(s[-5:])       // world

Negative indices count from the end. Omit start for 0, omit end for length.

Try operator (?)

The postfix ? operator works with Result values (Ok / Err). It unwraps Ok values and propagates Err values by returning early from the enclosing function:

fn divide(a, b) {
  if b == 0 {
    return Err("division by zero")
  }
  return Ok(a / b)
}

fn compute(x) {
  let result = divide(x, 2)?   // unwraps Ok, or returns Err early
  return Ok(result + 10)
}

fn compute_zero(x) {
  let result = divide(x, 0)?   // divide returns Err, ? propagates it
  return Ok(result + 10)
}

println(compute(20))       // Result.Ok(20)
println(compute_zero(20))  // Result.Err(division by zero)

Multiple ? calls can be chained in a single function to build pipelines that short-circuit on the first error.

Membership operators (in, not in)

Test whether a value is contained in a collection:

// Lists
println(3 in [1, 2, 3])          // true
println(6 not in [1, 2, 3])      // true

// Strings (substring containment)
println("world" in "hello world") // true
println("xyz" not in "hello")     // true

// Dicts (key membership)
let data = {name: "Alice", age: 30}
println("name" in data)           // true
println("email" not in data)      // true

// Sets
let s = set(1, 2, 3)
println(2 in s)                   // true
println(5 not in s)               // true

Control flow

if/else

if score > 90 {
  println("A")
} else if score > 80 {
  println("B")
} else {
  println("C")
}

Can be used as an expression: let grade = if score > 90 { "A" } else { "B" }

for/in

for item in [1, 2, 3] {
  println(item)
}

// Dict iteration yields {key, value} entries sorted by key
for entry in {a: 1, b: 2} {
  println("${entry.key}: ${entry.value}")
}

while

var i = 0
while i < 10 {
  println(i)
  i = i + 1
}

Safety limit of 10,000 iterations.

match

match status {
  "active" -> { println("Running") }
  "stopped" -> { println("Halted") }
}

Patterns are expressions compared by equality. First match wins. No match returns nil.

guard

Early exit if a condition isn’t met:

guard x > 0 else {
  return "invalid"
}
// x is guaranteed > 0 here

Ranges

Harn has a single range keyword: to. Ranges are inclusive by default1 to 5 is [1, 2, 3, 4, 5] — because that matches how the expression reads aloud. Add the trailing exclusive modifier when you want the half-open form.

for i in 1 to 5 {              // inclusive: 1, 2, 3, 4, 5
  println(i)
}

for i in 0 to 3 exclusive {    // half-open: 0, 1, 2
  println(i)
}

For Python-compatible 0-indexed iteration there is also a range() stdlib builtin. range(n) is equivalent to 0 to n exclusive; range(a, b) is a to b exclusive. Both forms always produce half-open integer ranges.

for i in range(5) { println(i) }        // 0, 1, 2, 3, 4
for i in range(3, 7) { println(i) }      // 3, 4, 5, 6

Iteration patterns

Prefer destructuring and stdlib helpers over integer-indexed loops — they read better and avoid off-by-one bugs.

// enumerate(): yields a list of {index, value} dicts.
for {index, value} in ["a", "b", "c"].enumerate() {
  println("${index}: ${value}")
}

// zip(): yields [a, b] pairs — use list destructuring.
for [name, score] in names.zip(scores) {
  println("${name}: ${score}")
}

// Dict iteration yields {key, value} entries sorted by key.
for {key, value} in {a: 1, b: 2}.entries() {
  println("${key} -> ${value}")
}

for heads currently accept a bare name, a list pattern [a, b], or a dict pattern {name1, name2}. Tuple patterns written with parentheses (for (a, b) in ...) are not yet supported — use the list pattern when the iterable yields pair-lists (zip), and the dict pattern when the iterable yields shaped dicts (enumerate, entries).

Functions and closures

Named functions

fn double(x) {
  return x * 2
}

fn greet(name: string) -> string {
  return "Hello, ${name}!"
}

Functions can be declared at the top level (for library files) or inside pipelines.

Rest parameters

Use ...name as the last parameter to collect any remaining arguments into a list:

fn sum(...nums) {
  var total = 0
  for n in nums {
    total = total + n
  }
  return total
}
println(sum(1, 2, 3))  // 6

fn log(level, ...parts) {
  println("[${level}] ${join(parts, " ")}")
}
log("INFO", "server", "started")  // [INFO] server started

If no extra arguments are provided, the rest parameter is an empty list.

Closures

let square = { x -> x * x }
let add = { a, b -> a + b }

println(square(4))     // 16
println(add(2, 3))     // 5

Closures capture their lexical environment at definition time. Parameters are immutable.

Higher-order functions

let nums = [1, 2, 3, 4, 5]

nums.map({ x -> x * 2 })           // [2, 4, 6, 8, 10]
nums.filter({ x -> x > 3 })        // [4, 5]
nums.reduce(0, { acc, x -> acc + x }) // 15
nums.find({ x -> x == 3 })         // 3
nums.any({ x -> x > 4 })           // true
nums.all({ x -> x > 0 })           // true
nums.flat_map({ x -> [x, x] })     // [1, 1, 2, 2, 3, 3, 4, 4, 5, 5]

Lazy iterators

Collection methods like .map and .filter above are eager — each call allocates a new list and walks the whole input. That’s fine for small inputs, but wastes work when you only need the first few results, or when you want to compose several transforms.

Harn also ships a lazy iterator protocol. Call .iter() on any iterable source (list, dict, set, string, generator, channel) to lift it into an Iter<T> — a single-pass, fused iterator. Combinators on an Iter return a new Iter without running any work. Sinks drain the iter and return an eager value.

let xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let first_three_doubled_evens = xs
  .iter()
  .filter({ x -> x % 2 == 0 })
  .map({ x -> x * 2 })
  .take(3)
  .to_list()
println(first_three_doubled_evens)  // [4, 8, 12]

Use .enumerate() to get (index, value) pairs in a for-loop:

let items = ["a", "b", "c"]
for (i, x) in items.iter().enumerate() {
  println("${i}: ${x}")
}

.iter() on a dict yields Pair(key, value) values — destructure them in a for-loop:

for (k, v) in {a: 1, b: 2}.iter() {
  println("${k}: ${v}")
}

A direct for entry in some_dict still yields the usual {key, value} dicts (back-compat). pair(a, b) also exists as a builtin for constructing pairs explicitly.

Lazy combinators (return a new Iter): .map, .filter, .flat_map, .take(n), .skip(n), .take_while, .skip_while, .zip, .enumerate, .chain, .chunks(n), .windows(n).

Sinks (drain the iter, return a value): .to_list(), .to_set(), .to_dict() (requires Pair items), .count(), .sum(), .min(), .max(), .reduce(init, f), .first(), .last(), .any(p), .all(p), .find(p), .for_each(f).

When to use which: reach for eager list/dict/set methods for simple one-shot transforms where you want a collection back. Reach for .iter() when you’re composing multiple transforms, taking the first N results of a large input, consuming a generator lazily, or driving a for-loop over combined sources.

Iterators are single-pass and fused — once exhausted, they stay exhausted. Iteration takes a snapshot of the backing collection, so mutating the source after .iter() does not affect the iter. Printing an iter renders <iter> without draining it.

Numeric ranges (a to b, range(n)) participate in the lazy iter protocol directly: .map / .filter / .take / .zip / .enumerate / ... on a Range return a lazy iter with no upfront allocation, so (1 to 10_000_000).map(fn(x) { return x * 2 }).take(5).to_list() finishes instantly. Range still keeps its O(1) fast paths for .len / .first / .last / .contains(x) and r[k] subscript — those don’t round-trip through iter.

Pipe operator

The pipe operator |> passes the left side as the argument to the right side:

let result = data
  |> { list -> list.filter({ x -> x > 0 }) }
  |> { list -> list.map({ x -> x * 2 }) }
  |> json_stringify

Pipe placeholder (_)

Use _ to control where the piped value is placed in the call:

"hello world" |> split(_, " ")       // ["hello", "world"]
[3, 1, 2] |> _.sort()               // [1, 2, 3]
items |> len(_)                      // length of items
"world" |> replace("hello _", "_", _) // "hello world"

Without _, the value is passed as the sole argument to a closure or function name.

Multiline expressions

Binary operators, method chains, and pipes can span multiple lines:

let message = "hello"
  + " "
  + "world"

let result = items
  .filter({ x -> x > 0 })
  .map({ x -> x * 2 })

let valid = check_a()
  && check_b()
  || fallback()

Note: - does not continue across lines because it doubles as unary negation.

A backslash at the end of a line forces the next line to continue the current expression, even when no operator is present:

let long_value = some_function( \
  arg1, arg2, arg3 \
)

Destructuring

Destructuring extracts values from dicts and lists into local variables.

Dict destructuring

let person = {name: "Alice", age: 30}
let {name, age} = person
println(name)  // "Alice"
println(age)   // 30

List destructuring

let items = [1, 2, 3, 4, 5]
let [first, ...rest] = items
println(first)  // 1
println(rest)   // [2, 3, 4, 5]

Renaming

Use : to bind a dict field to a different variable name:

let data = {name: "Alice"}
let {name: user_name} = data
println(user_name)  // "Alice"

Destructuring in for-in loops

let entries = [{key: "a", value: 1}, {key: "b", value: 2}]
for {key, value} in entries {
  println("${key}: ${value}")
}

Default values

Pattern fields can specify defaults with = expr. The default is used when the value would otherwise be nil:

let { name = "anon", role = "user" } = { name: "Alice" }
println(name)  // Alice
println(role)  // user

let [a = 0, b = 0, c = 0] = [1, 2]
println(c)     // 0

// Combine with renaming
let { name: display = "Unknown" } = {}
println(display)  // Unknown

Missing keys and empty rest

Missing keys destructure to nil (unless a default is specified). A rest pattern with no remaining items gives an empty collection:

let {name, email} = {name: "Alice"}
println(email)  // nil

let [only, ...rest] = [42]
println(rest)   // []

Collections

Lists

let nums = [1, 2, 3]
nums.count          // 3
nums.first          // 1
nums.last           // 3
nums.empty          // false
nums[0]             // 1 (subscript access)

Lists support + for concatenation: [1, 2] + [3, 4] yields [1, 2, 3, 4]. Assigning to an out-of-bounds index throws an error.

Dicts

let user = {name: "Alice", age: 30}
user.name           // "Alice" (property access)
user["age"]         // 30 (subscript access)
user.missing        // nil (missing keys return nil)
user.has("email")   // false

user.keys()         // ["age", "name"] (sorted)
user.values()       // [30, "Alice"]
user.entries()      // [{key: "age", value: 30}, ...]
user.merge({role: "admin"})  // new dict with merged keys
user.map_values({ v -> to_string(v) })
user.filter({ v -> type_of(v) == "int" })

Computed keys use bracket syntax: {[dynamic_key]: value}.

Quoted string keys are also supported for JSON compatibility: {"content-type": "json"}. The formatter normalizes simple quoted keys to unquoted form and non-identifier keys to computed key syntax.

Keywords can be used as dict keys and property names: {type: "read"}, op.type.

Dicts iterate in sorted key order (alphabetical). This means for k in dict is deterministic and reproducible, but does not preserve insertion order.

Sets

Sets are unordered collections of unique values. Duplicates are automatically removed.

let s = set(1, 2, 3)          // create from individual values
let s2 = set([4, 5, 5, 6])   // create from a list (deduplicates)
let tags = set("a", "b", "c") // works with any value type

Set operations are provided as builtin functions:

let a = set(1, 2, 3)
let b = set(3, 4, 5)

set_contains(a, 2)       // true
set_contains(a, 99)      // false

set_union(a, b)          // set(1, 2, 3, 4, 5)
set_intersect(a, b)      // set(3)
set_difference(a, b)     // set(1, 2) -- items in a but not in b

set_add(a, 4)            // set(1, 2, 3, 4)
set_remove(a, 2)         // set(1, 3)

Sets support iteration with for..in:

var sum = 0
for item in set(10, 20, 30) {
  sum = sum + item
}
println(sum)  // 60

Convert a set to a list with to_list():

let items = to_list(set(10, 20))
type_of(items)  // "list"

Enums and structs

Enums

enum Status {
  Active
  Inactive
  Pending(reason)
  Failed(code, message)
}

let s = Status.Pending("waiting")
match s.variant {
  "Pending" -> { println(s.fields[0]) }
  "Active" -> { println("ok") }
}

Structs

struct Point {
  x: int
  y: int
}

let p = {x: 10, y: 20}
println(p.x)

Structs can also be constructed with the struct name as a constructor, using named fields directly:

let p = Point { x: 10, y: 20 }
println(p.x)  // 10

Structs can declare type parameters when fields should stay connected:

struct Pair<A, B> {
  first: A
  second: B
}

let pair: Pair<int, string> = Pair { first: 1, second: "two" }
println(pair.second)  // two

Impl blocks

Add methods to a struct with impl:

struct Point {
  x: int
  y: int
}

impl Point {
  fn distance(self) {
    return sqrt(self.x * self.x + self.y * self.y)
  }
  fn translate(self, dx, dy) {
    return Point { x: self.x + dx, y: self.y + dy }
  }
}

let p = Point { x: 3, y: 4 }
println(p.distance())       // 5.0
println(p.translate(10, 20)) // Point({x: 13, y: 24})

The first parameter must be self, which receives the struct instance. Methods are called with dot syntax on values constructed with the struct constructor.

Interfaces

Interfaces let you define a contract: a set of methods that a type must have. Harn uses implicit satisfaction, just like Go. A struct satisfies an interface automatically if its impl block has all the required methods. You never write implements or impl Interface for Type.

Step 1: Define an interface

An interface lists method signatures without bodies:

interface Displayable {
  fn display(self) -> string
}

This says: any type that has a display(self) -> string method counts as Displayable.

Interfaces can also be generic, and individual interface methods may declare their own type parameters when the contract needs them:

interface Repository<T> {
  fn get(id: string) -> T
  fn map<U>(value: T, f: fn(T) -> U) -> U
}

Interfaces may also declare associated types when the contract needs to name an implementation-defined type without making the whole interface generic:

interface Collection {
  type Item
  fn get(self, index: int) -> Item
}

Step 2: Create structs with matching methods

struct Dog {
  name: string
  breed: string
}

impl Dog {
  fn display(self) -> string {
    return "${self.name} the ${self.breed}"
  }
}

struct Cat {
  name: string
  indoor: bool
}

impl Cat {
  fn display(self) -> string {
    let status = if self.indoor { "indoor" } else { "outdoor" }
    return "${self.name} (${status} cat)"
  }
}

Both Dog and Cat have a display(self) -> string method, so they both satisfy Displayable. No extra annotation is needed.

Step 3: Use the interface as a type

Now you can write a function that accepts any Displayable:

fn introduce(animal: Displayable) {
  println("Meet: ${animal.display()}")
}

let d = Dog({name: "Rex", breed: "Labrador"})
let c = Cat({name: "Whiskers", indoor: true})

introduce(d)  // Meet: Rex the Labrador
introduce(c)  // Meet: Whiskers (indoor cat)

The type checker verifies at compile time that Dog and Cat satisfy Displayable. If a struct is missing a required method, you get a clear error at the call site.

Interfaces with multiple methods

Interfaces can require more than one method:

interface Serializable {
  fn serialize(self) -> string
  fn byte_size(self) -> int
}

guard, require, and assert

These three forms serve different jobs:

  • guard condition else { ... } handles expected control flow and narrows types after the guard.
  • require condition, "message" enforces runtime invariants in normal code and throws on failure.
  • assert, assert_eq, and assert_ne are for test pipelines. The linter warns when you use them in non-test code, and it nudges test pipelines away from require.
guard user != nil else {
  return "missing user"
}

require len(user.name) > 0, "user name cannot be empty"

A struct must implement all listed methods to satisfy the interface.

Generic constraints

You can also use interfaces as constraints on generic type parameters:

fn log_item<T>(item: T) where T: Displayable {
  println("[LOG] ${item.display()}")
}

The where T: Displayable clause tells the type checker to verify that whatever concrete type is passed for T satisfies Displayable. If it does not, a compile-time error is produced. Generic parameters must also bind consistently across arguments, so fn<T>(a: T, b: T) cannot be called with mixed concrete types such as (int, string). Container bindings like list<T> preserve and validate their element type at call sites too.

Variance: in T and out T

Type parameters on user-defined generics may be marked in (the parameter is contravariant — it appears only in input positions) or out (covariant — only in output positions). Unannotated parameters default to invariant: Box<int> and Box<float> are unrelated unless Box declares out T and uses T only covariantly.

type Reader<out T> = fn() -> T          // T is produced
interface Sink<in T> { fn accept(v: T) -> int }  // T is consumed

Built-in containers carry sensible variance: iter<T> is covariant (read-only), but list<T> and dict<K, V> are invariant (mutable). Function types are contravariant in their parameters and covariant in their return type — fn(float) can stand in for fn(int), but not the other way around. The full variance table lives in the spec under “Subtyping and variance”.

Declarations are checked at the definition site: a type Box<out T> = fn(T) -> int is rejected because T appears in a contravariant position despite the out annotation.

Spread in function calls

The spread operator ... expands a list into individual function arguments:

fn add(a, b, c) {
  return a + b + c
}

let nums = [1, 2, 3]
println(add(...nums))  // 6

You can mix regular arguments and spread arguments:

fn add(a, b, c) {
  return a + b + c
}

let rest = [2, 3]
println(add(1, ...rest))  // 6

Spread works in method calls too:

let point = Point({x: 0, y: 0})
let deltas = [10, 20]
let moved = point.translate(...deltas)

Try-expression

The try keyword without a catch block is a try-expression. It evaluates its body and wraps the outcome in a Result:

let result = try { json_parse(raw_input) }
// Result.Ok(parsed_data)  -- if parsing succeeds
// Result.Err("invalid JSON: ...") -- if parsing throws

This is the complement of the ? operator. Use try to enter Result-land (catching errors into Result.Err), and ? to exit Result-land (propagating errors upward):

fn safe_divide(a, b) {
  return try { a / b }
}

fn compute(x) {
  let half = safe_divide(x, 2)?  // unwrap Ok or propagate Err
  return Ok(half + 10)
}

No catch or finally is needed. If a catch follows try, it is parsed as the traditional try/catch statement instead.

Ask expression

The ask expression is syntactic sugar for making an LLM call. It takes a set of key-value fields and returns the LLM response as a string:

let answer = ask {
  system: "You are a helpful assistant.",
  user: "What is 2 + 2?"
}
println(answer)

Common fields include system (system prompt), user (user message), model, max_tokens, and provider. The ask expression is equivalent to building a dict and passing it to llm_call.

Duration literals

let d1 = 500ms   // 500 milliseconds
let d2 = 5s      // 5 seconds
let d3 = 2m      // 2 minutes
let d4 = 1h      // 1 hour

Durations can be passed to sleep() and used in deadline blocks.

Math constants

pi and e are global constants (not functions):

println(pi)    // 3.141592653589793
println(e)     // 2.718281828459045

let area = pi * r * r

Named format placeholders

The format builtin supports both positional {} placeholders and named {key} placeholders when the second argument is a dict:

// Positional
println(format("Hello, {}!", "world"))

// Named
println(format("Hello {name}, you are {age}.", {name: "Alice", age: 30}))

For simple cases, string interpolation with ${} is usually more convenient:

let name = "Alice"
println("Hello, ${name}!")

Comments

// Line comment

/** HarnDoc comment for a public API.
    Use a `/** ... */` block directly above `pub fn`. */
pub fn greet(name: string) -> string {
  return "Hello, ${name}"
}

pub pipeline deploy(task) {
  return
}

pub enum Result {
  Ok(value: string)
  Err(message: string)
}

pub struct Config {
  host: string
  port?: int
}

/* Block comment
   /* Nested block comments are supported */
   Still inside the outer comment */