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.

Pipelines

Pipelines are the top-level organizational unit. A Harn program is one or more pipelines. The runtime executes the pipeline named default, or the first one declared.

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

pipeline other(task) {
  log("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

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"
log("Hello, ${name}!")
log("2 + 2 = ${2 + 2}")

Any expression works inside ${}.

Multi-line strings

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

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< > <= >=Comparison
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.

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)
}

log(compute(20))       // Result.Ok(20)
log(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.

Control flow

if/else

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

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

for/in

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

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

while

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

Safety limit of 10,000 iterations.

match

match status {
  "active" -> { log("Running") }
  "stopped" -> { log("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

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

for i in 0 upto 3 {   // exclusive: 0, 1, 2
  log(i)
}

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.

Closures

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

log(square(4))     // 16
log(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]

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
log(name)  // "Alice"
log(age)   // 30

List destructuring

let items = [1, 2, 3, 4, 5]
let [first, ...rest] = items
log(first)  // 1
log(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
log(user_name)  // "Alice"

Destructuring in for-in loops

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

Missing keys and empty rest

Missing keys destructure to nil. A rest pattern with no remaining items gives an empty collection:

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

let [only, ...rest] = [42]
log(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
}
log(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" -> { log(s.fields[0]) }
  "Active" -> { log("ok") }
}

Structs

struct Point {
  x: int
  y: int
}

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

Structs can also be constructed with the struct name as a constructor, which tags the value with the struct type:

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

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})
log(p.distance())       // 5.0
log(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.

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
}

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 warning is produced.

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:

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.

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.

Comments

// Line comment

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