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

Introduction

This document is the formal specification for the Forge programming language, version 0.3.3.

Forge is an internet-native, general-purpose programming language implemented in Rust. It is designed for application-layer work: web services, scripts, data pipelines, prototypes, and tooling. Forge ships with built-in support for HTTP clients and servers, databases (SQLite and PostgreSQL), cryptography, JSON, CSV, terminal UI, AI/LLM integration, and more — eliminating the need for third-party packages for common internet-oriented tasks.

Design Philosophy

Forge is guided by three core principles:

  1. Internet-native. The operations developers perform most frequently — HTTP requests, database queries, JSON parsing, cryptographic hashing — are built into the language and its standard library. A REST API server is four lines. A database query is two.

  2. Dual syntax. Every construct in Forge has two spellings: a classic syntax familiar to developers who have written JavaScript, Python, Rust, or Go; and a natural-language syntax that reads like English prose. Both forms compile to identical internal representations and may be mixed freely within the same source file.

  3. Batteries included. Forge ships 16 standard library modules with over 230 functions, 30 interactive tutorials, a built-in test runner, a formatter, an LSP server, and a project scaffolding tool. A single cargo install provides a complete development environment.

Language Overview

Forge is dynamically typed at runtime with optional type annotations (gradual typing). It supports first-class functions, closures, algebraic data types, pattern matching, Result/Option error handling, structural interfaces, composition via delegation, async/await concurrency, and channels. Programs are executed top-to-bottom without requiring a main function.

The default execution engine is a tree-walking interpreter. A bytecode VM (--vm) and a JIT compiler (--jit) are available for performance-critical workloads but support fewer features.

How to Read This Specification

This specification is organized into five parts:

  • Part I: Language Core covers lexical structure, types, expressions, statements, the type system, error handling, and concurrency.
  • Part II: Standard Library documents each of the 16 built-in modules.
  • Part III: Built-in Functions catalogs all globally available functions.
  • Part IV: Dual Syntax Reference provides the complete mapping between classic and natural syntax forms.
  • Part V: Runtime and Internals describes the interpreter, bytecode VM, JIT compiler, and HTTP server runtime.

Grammar rules are presented in block quotes using an informal EBNF notation. Code examples use forge syntax highlighting. Where both classic and natural syntax exist for a construct, both forms are shown.

Notation Conventions

Throughout this specification:

  • monospace text in prose refers to keywords, operators, or identifiers.
  • Code blocks labeled forge contain valid Forge source code.
  • Grammar productions use for derivation, | for alternatives, [ ] for optional elements, and { } for zero-or-more repetition.
  • The term “implementation-defined” means the behavior is determined by the specific execution engine (interpreter, VM, or JIT).

Credits

Forge was created by Archith Rapaka / HumanCTO.

Lexical Structure

This chapter defines the lexical grammar of Forge: how source text is decomposed into a sequence of tokens. The lexer (tokenizer) reads UTF-8 encoded source text and produces a flat stream of tokens that the parser consumes.

Overview

A Forge source file is a sequence of Unicode characters encoded as UTF-8. The lexer processes this character stream left-to-right, greedily matching the longest valid token at each position. The resulting token stream consists of:

  • Keywords — reserved words with special meaning (e.g., let, fn, set, define)
  • Identifiers — user-defined names for variables, functions, types, and fields
  • Literals — integer, float, string, boolean, and null values
  • Operators — arithmetic, comparison, logical, assignment, and special operators
  • Punctuation — delimiters and separators ((, ), {, }, [, ], ,, :, ;)
  • Comments — line comments and block comments, discarded during tokenization
  • Newlines — significant for statement termination
  • Decorators@ prefixed annotations

Whitespace and Line Termination

Spaces (U+0020) and horizontal tabs (U+0009) are whitespace characters. They separate tokens but are otherwise insignificant and are not included in the token stream.

Newline characters (U+000A, and the sequence U+000D U+000A) serve as statement terminators. Unlike whitespace, newlines are emitted as Newline tokens because Forge uses newlines (rather than semicolons) to separate statements. Semicolons (;) are recognized as explicit statement terminators but are not required.

Tokenization Order

When the lexer encounters a character sequence, it applies the following precedence:

  1. Skip whitespace (spaces and tabs).
  2. If the character begins a comment (// or /*), consume the entire comment.
  3. If the character is a newline, emit a Newline token.
  4. If the character is a digit, lex a numeric literal (integer or float).
  5. If the character is ", lex a string literal (or """ for raw strings).
  6. If the character is a letter or underscore, lex an identifier or keyword.
  7. Otherwise, lex an operator or punctuation token.

Each token carries a span consisting of the line number, column number, byte offset, and byte length within the source text.

Subsections

The following subsections define each lexical element in detail:

Source Text

Encoding

Forge source files must be encoded in UTF-8. The lexer assumes UTF-8 input and will produce errors on invalid byte sequences. No byte-order mark (BOM) is required or expected; if present, it is treated as ordinary content and will likely cause a parse error.

File Extension

Forge source files use the .fg file extension by convention. The CLI tools (forge run, forge test, forge fmt) expect this extension. Example:

hello.fg
server.fg
tests/math_test.fg

Line Endings

Forge recognizes two line ending sequences:

SequenceNameUnicode
\nLine feedU+000A
\r\nCarriage return + line feedU+000D U+000A

A bare carriage return (\r without a following \n) is not treated as a line ending. Both recognized forms are normalized to a single Newline token in the token stream.

Source Structure

A Forge source file consists of a sequence of top-level statements executed in order. There is no required main function, module declaration, or package header. The simplest valid Forge program is:

say "hello"

Forge programs are executed from the first statement to the last, top to bottom. Functions and type definitions are hoisted conceptually in that they can be referenced before their textual position, but side effects in top-level statements execute in source order.

Character Set

Within string literals, Forge supports the full Unicode character set. Outside of string literals, the following characters are meaningful to the lexer:

  • ASCII letters (a-z, A-Z) and underscore (_) begin identifiers and keywords.
  • ASCII digits (0-9) begin numeric literals.
  • Operator and punctuation characters: +, -, *, /, %, =, !, <, >, &, |, ., ,, :, ;, (, ), {, }, [, ], @, ?, #.
  • The double-quote character (") begins string literals.
  • Whitespace characters: space (U+0020), horizontal tab (U+0009).
  • Line terminators: line feed (U+000A), carriage return (U+000D).

All other characters outside of string literals are lexer errors.

Keywords

Keywords are reserved identifiers with special syntactic meaning. A keyword cannot be used as a variable name, function name, or type name.

Forge’s keyword set is organized into five categories: classic keywords (familiar from other languages), natural-language keywords (Forge’s English-like alternatives), innovation keywords (unique to Forge), error handling keywords, and type keywords.

Classic Keywords

These keywords provide syntax familiar to developers coming from Rust, JavaScript, Go, or Python.

KeywordPurpose
letVariable declaration
mutMutable variable modifier
fnFunction declaration
returnReturn from function
ifConditional branch
elseAlternative branch
matchPattern matching
forFor loop
inIterator binding (used with for)
whileWhile loop
loopInfinite loop
breakExit a loop
continueSkip to next loop iteration
structStruct type definition
typeAlgebraic data type definition
interfaceInterface definition
implMethod block / interface implementation
pubPublic visibility modifier
importModule import
spawnSpawn a concurrent task
trueBoolean literal true
falseBoolean literal false
nullNull literal
asyncAsync function declaration
awaitAwait an async expression
yieldYield a value from a generator

Natural-Language Keywords

These keywords provide English-like alternatives to classic syntax. Each natural keyword maps to an equivalent classic construct.

Natural KeywordClassic EquivalentUsage
setletset x to 5
to=Used with set and change
change(reassignment)change x to 10
definefndefine greet(name) { }
otherwiseelse} otherwise { }
nahelse} nah { } (informal)
each(loop modifier)for each x in items { }
repeat(counted loop)repeat 5 times { }
times(loop count)Used with repeat
grab(fetch)grab resp from "url"
from(source)Used with grab and unpack
wait(sleep)wait 2 seconds
seconds(time unit)Used with wait and timeout
sayprintlnsay "hello"
yell(uppercase print)yell "hello" prints HELLO
whisper(lowercase print)whisper "HELLO" prints hello
thingstructthing Person { }
powerinterfacepower Describable { }
giveimplgive Person { }
craft(constructor)craft Person { name: "A" }
the(connector)give X the power Y { }
forgeasyncforge fetch_data() { }
holdawaithold expr
emityieldemit value
unpack(destructure)unpack {a, b} from obj

Dual Syntax Mapping

The following table shows equivalent forms for the most common constructs:

ConstructClassicNatural
Variablelet x = 5set x to 5
Mutable variablelet mut x = 0set mut x to 0
Reassignmentx = 10change x to 10
Functionfn add(a, b) { }define add(a, b) { }
Else branchelse { }otherwise { } / nah { }
Struct definitionstruct Point { }thing Point { }
Interfaceinterface I { }power I { }
Impl blockimpl T { }give T { }
Impl for interfaceimpl I for T { }give T the power I { }
ConstructorPoint { x: 1 }craft Point { x: 1 }
Async functionasync fn f() { }forge f() { }
Awaitawait exprhold expr
Yieldyield valueemit value
Destructurelet {a, b} = objunpack {a, b} from obj

Innovation Keywords

These keywords introduce constructs unique to Forge that have no direct equivalent in other mainstream languages.

KeywordPurposeExample
whenGuard expression (multi-way conditional)when age { < 13 -> "kid" }
unlessPostfix negative conditionalexpr unless condition
untilPostfix loop-untilexpr until condition
mustCrash on error with messagemust parse_int(s)
checkDeclarative validationcheck name is not empty
safeNull-safe execution blocksafe { risky_code() }
whereCollection filteritems where x > 5
timeoutTime-limited executiontimeout 5 seconds { }
retryAutomatic retry with countretry 3 times { }
scheduleCron-style schedulingschedule every 5 minutes { }
everyUsed with scheduleschedule every N { }
anyExistential quantifierany x in items
askAI/LLM promptask "summarize this"
promptPrompt template definitionprompt summarize() { }
transformData transformation blocktransform data { }
tableTable displaytable [...]
selectQuery-style selectfrom X select Y
orderQuery-style orderingorder by field
byUsed with order and sortorder by name
limitQuery-style limitlimit 10
keepFilter synonymkeep where condition
takeTake N itemstake 5
freezeFreeze/immobilize a valuefreeze expr
watchFile change detectionwatch "file.txt" { }
downloadDownload a file from URLdownload "url" to "path"
crawlWeb scrapingcrawl "url"

Error Handling Keywords

KeywordPurposeExample
tryTry blocktry { }
catchCatch blockcatch err { }

Type Keywords

These identifiers are reserved as built-in type names. They are recognized by the lexer as keyword tokens, not as general identifiers.

KeywordType
Int64-bit signed integer
Float64-bit IEEE 754 float
StringUTF-8 string
BoolBoolean
JsonJSON value type

Operators as Keywords

The following operators are lexed as keyword tokens rather than punctuation:

TokenKeywordMeaning
|>PipePipe-forward operator
>>PipeRightAlternate pipe operator
...DotDotDotSpread operator
+=PlusEqAdd-assign
-=MinusEqSubtract-assign
*=StarEqMultiply-assign
/=SlashEqDivide-assign

Complete Alphabetical Index

For reference, the complete set of keyword strings recognized by the lexer (case-sensitive):

Int, Float, String, Bool, Json,
any, ask, async, await, break, by, catch, change, continue, craft,
crawl, define, download, each, else, emit, every, false, fn, for,
forge, freeze, from, give, grab, hold, if, impl, import, in,
interface, keep, let, limit, loop, match, mut, nah, null, order,
otherwise, power, prompt, pub, repeat, retry, return, safe, say,
schedule, seconds, select, set, spawn, struct, table, take, the,
thing, timeout, times, to, transform, true, try, type, unless,
unpack, until, wait, watch, when, where, while, whisper, yell, yield

All keywords are case-sensitive. Let and LET are identifiers, not keywords. The type keywords Int, Float, String, Bool, and Json are the only keywords that begin with an uppercase letter.

Identifiers

An identifier is a name that refers to a variable, function, type, field, or module. Identifiers are the primary mechanism for binding values to names in Forge programs.

Syntax

IdentifierIdentStart IdentContinue*

IdentStarta-z | A-Z | _

IdentContinueIdentStart | 0-9

An identifier begins with an ASCII letter or underscore, followed by zero or more ASCII letters, digits, or underscores. Identifiers have no maximum length.

x
name
_private
camelCase
snake_case
PascalCase
item2
MAX_RETRIES
__internal

Case Sensitivity

Identifiers are case-sensitive. The names name, Name, and NAME refer to three distinct bindings.

let name = "alice"
let Name = "Bob"
let NAME = "CHARLIE"
say name   // alice
say Name   // Bob
say NAME   // CHARLIE

Reserved Words

If an identifier matches a keyword string (see Keywords), it is lexed as that keyword token rather than as an Ident token. Keywords cannot be used as identifiers.

// Error: 'let' is a keyword, not a valid variable name
let let = 5  // parse error

Naming Conventions

Forge does not enforce naming conventions, but the following are idiomatic:

ElementConventionExample
Variablessnake_caseuser_name
Functionssnake_caseget_user
Types (structs)PascalCaseHttpRequest
InterfacesPascalCaseDescribable
ConstantsUPPER_SNAKEMAX_RETRIES
Modulessnake_casemath, fs, json

The it Identifier

The identifier it has special meaning inside method blocks defined with give (or impl). When used as the first parameter of a method, it refers to the receiver instance — the object on which the method was called.

thing Person {
    name: String,
    age: Int
}

give Person {
    define greet(it) {
        return "Hi, I'm " + it.name
    }
}

set p to craft Person { name: "Alice", age: 30 }
say p.greet()  // Hi, I'm Alice

When p.greet() is called, the value of p is automatically bound to it inside the method body. The caller does not pass it explicitly.

If the first parameter of a method is not named it, the method is treated as a static method — it is called on the type itself rather than on an instance:

give Person {
    define infant(name) {
        return craft Person { name: name, age: 0 }
    }
}

set baby to Person.infant("Bob")

Outside of method blocks, it has no special meaning and may be used as an ordinary identifier, though this is discouraged for clarity.

Underscore

A lone underscore (_) is a valid identifier. By convention, it is used as a placeholder for values that are intentionally ignored:

match result {
    Ok(_) => say "success"
    Err(msg) => say "error: {msg}"
}
for _, value in enumerate(items) {
    say value
}

Shadowing

A new let or set declaration may reuse an identifier that is already in scope. The new binding shadows the previous one within the inner scope:

let x = 10
say x        // 10

if true {
    let x = 20
    say x    // 20 (shadows outer x)
}

say x        // 10 (outer x is unchanged)

Shadowing creates a new binding; it does not mutate the original variable.

Literals

A literal is a notation for representing a fixed value in source code. Forge supports integer, float, string, raw string, boolean, null, array, and object literals.

Integer Literals

IntLiteralDigit+

Digit0-9

An integer literal is a sequence of one or more decimal digits. Integer literals produce values of type Int (64-bit signed integer).

0
42
1000000

Negative integer values are expressed using the unary negation operator:

let x = -42

Integer literals do not support underscores as digit separators, hexadecimal, octal, or binary notation in the current version.

Float Literals

FloatLiteralDigit+ . Digit+

A float literal contains a decimal point with digits on both sides. Float literals produce values of type Float (64-bit IEEE 754 double-precision).

3.14
0.5
100.0

A leading dot (.5) or trailing dot (5.) is not valid. Both sides of the decimal point must have at least one digit.

Negative float values use unary negation:

let temp = -0.5

Scientific notation (e.g., 1.5e10) is not supported in the current version.

String Literals

StringLiteral" StringContent* "

StringContentCharacter | EscapeSequence | Interpolation

EscapeSequence\n | \t | \\ | \"

Interpolation{ Expression }

A string literal is a sequence of characters enclosed in double quotes. String literals produce values of type String (UTF-8 encoded, immutable).

"hello, world"
"line one\nline two"
"she said \"hi\""

Escape Sequences

The following escape sequences are recognized within string literals:

SequenceCharacter
\nNewline (U+000A)
\tHorizontal tab (U+0009)
\\Backslash (U+005C)
\"Double quote (U+0022)

String Interpolation

Curly braces within a string literal delimit an interpolation expression. The expression is evaluated at runtime, converted to a string, and inserted at that position:

let name = "Forge"
let version = 3
say "Welcome to {name} v{version}!"
// Output: Welcome to Forge v3!

Interpolation supports arbitrary expressions, not just variable names:

let x = 7
say "Seven squared is {x * x}"
say "Length: {len("hello")}"
say "Upper: {name}"

To include a literal { in a string without triggering interpolation, there is no dedicated escape sequence in the current version. Use string concatenation or a variable if needed.

Raw String Literals

RawStringLiteral""" RawContent* """

A raw string literal is delimited by triple double quotes. Raw strings preserve their content exactly as written: no escape sequences are processed and no interpolation occurs.

let sql = """SELECT * FROM users WHERE active = true"""

let html = """<div class="container">
    <h1>Hello</h1>
</div>"""

Raw strings may span multiple lines. They are particularly useful for SQL queries, regular expressions, and embedded markup.

Boolean Literals

BoolLiteraltrue | false

The boolean literals true and false produce values of type Bool. They are keyword tokens.

let active = true
let deleted = false

Null Literal

NullLiteralnull

The null literal represents the absence of a value. It produces a value of type Null. It is a keyword token.

let nothing = null
say typeof(nothing)  // Null

Array Literals

ArrayLiteral[ ( Expression ( , Expression )* ,? )? ]

An array literal is a comma-separated list of expressions enclosed in square brackets. Arrays are ordered, heterogeneous (elements may have different types), and 0-indexed.

let empty = []
let nums = [1, 2, 3]
let mixed = [1, "two", true, null]
let nested = [[1, 2], [3, 4]]

A trailing comma after the last element is permitted.

Object Literals

ObjectLiteral{ ( Field ( , Field )* ,? )? }

FieldIdentifier : Expression

An object literal is a comma-separated list of key-value pairs enclosed in curly braces. Keys are identifiers (unquoted). Objects maintain insertion order.

let empty = {}
let user = { name: "Alice", age: 30 }
let config = {
    host: "localhost",
    port: 8080,
    debug: false,
}

Object keys are strings at runtime, even though they appear as bare identifiers in the literal syntax. A trailing comma after the last field is permitted.

Shorthand Field Syntax

When a variable name matches the desired key name, the value may be omitted:

let name = "Alice"
let age = 30
let user = { name, age }
// Equivalent to: { name: "Alice", age: 30 }

Operators and Punctuation

This section defines all operator and punctuation tokens in Forge.

Arithmetic Operators

TokenNameExampleDescription
+Plusa + bAddition; string concatenation
-Minusa - bSubtraction; unary negation
*Stara * bMultiplication
/Slasha / bDivision
%Percenta % bModulo (remainder)

When both operands of / are integers, the result is an integer (truncating division). When either operand is a float, the result is a float.

The + operator is overloaded for string concatenation when both operands are strings.

Comparison Operators

TokenNameExampleDescription
==Equala == bEquality test
!=Not equala != bInequality test
<Less thana < bLess-than comparison
>Greater thana > bGreater-than comparison
<=Less than or equala <= bLess-than-or-equal
>=Greater than or equala >= bGreater-than-or-equal

All comparison operators return a Bool value. Strings are compared lexicographically.

Logical Operators

TokenNameExampleDescription
&&Logical ANDa && bShort-circuit conjunction
||Logical ORa || bShort-circuit disjunction
!Logical NOT!aUnary boolean negation

The keywords and and or are not reserved keywords in Forge. Logical operations use the symbolic && and || forms exclusively. The not keyword is also not reserved; use the ! prefix operator.

Both && and || use short-circuit evaluation: the right operand is not evaluated if the result can be determined from the left operand alone.

Assignment Operators

TokenNameExampleEquivalent
=Assignmentx = 5
+=Add-assignx += 5x = x + 5
-=Subtract-assignx -= 3x = x - 3
*=Multiply-assignx *= 2x = x * 2
/=Divide-assignx /= 4x = x / 4

Assignment and compound assignment operators require the left-hand side to be a mutable variable (declared with mut). Compound assignment is syntactic sugar for the expanded form.

Member Access and Navigation

TokenNameExampleDescription
.Dotobj.fieldField access, method call
..Range1..10Range constructor (exclusive end)

The dot operator accesses fields on objects and struct instances, and invokes methods. It binds very tightly (highest precedence among binary operators).

The range operator .. creates a range value from a start (inclusive) to an end (exclusive). It is used primarily with for loops and the range() built-in.

Pipe Operators

TokenNameExampleDescription
|>Pipex |> fPipe-forward: passes left as first argument to right
>>Pipe rightx >> fAlternate pipe operator

The pipe operator passes the value on the left as the first argument to the function on the right:

let result = [3, 1, 4, 1, 5]
    |> sort
    |> reverse

Spread Operator

TokenNameExampleDescription
...Spread[...arr, 4]Spreads array/object elements

The spread operator expands an array or object into individual elements within an array literal or object literal:

let a = [1, 2, 3]
let b = [...a, 4, 5]       // [1, 2, 3, 4, 5]

let base = { x: 1, y: 2 }
let ext = { ...base, z: 3 } // { x: 1, y: 2, z: 3 }

Arrow Operators

TokenNameExampleDescription
->Arrow< 13 -> "kid"Arm separator in when/match
=>Fat arrowOk(v) => say vArm separator in match

The thin arrow -> is used in when guard arms. The fat arrow => is used in match pattern arms. Both separate a pattern/condition from its corresponding body.

Special Operators

TokenNameExampleDescription
?Questionexpr?Error propagation (Result/Option)
@At@testDecorator prefix
&Ampersand(reserved)Reserved for future use
|BarCircle(r) | Rect(w, h)ADT variant separator

The ? postfix operator propagates errors: if the expression evaluates to Err(e), the enclosing function returns Err(e) immediately. If the expression is Ok(v), the ? unwraps it to v.

Delimiters

TokenNamePurpose
(Left parenFunction call, grouping
)Right parenClose function call, grouping
{Left braceBlock, object literal, interpolation
}Right braceClose block, object, interpolation
[Left bracketArray literal, index access
]Right bracketClose array, index access
,CommaSeparator in lists
:ColonKey-value separator, type annotation
;SemicolonOptional statement terminator

Operator Precedence

Operators are listed from highest to lowest precedence:

PrecedenceOperatorsAssociativity
1 (highest). (member access)Left-to-right
2() (call), [] (index)Left-to-right
3!, - (unary)Right-to-left
4*, /, %Left-to-right
5+, -Left-to-right
6..Left-to-right
7<, >, <=, >=Left-to-right
8==, !=Left-to-right
9&&Left-to-right
10||Left-to-right
11|>Left-to-right
12?Postfix
13 (lowest)=, +=, -=, *=, /=Right-to-left

Parentheses may be used to override the default precedence.

Comments

Comments are annotations in source code intended for human readers. The lexer recognizes comments and discards them; they do not appear in the token stream and have no effect on program behavior.

Forge supports two forms of comments: line comments and block comments.

Line Comments

LineComment// Character* Newline

A line comment begins with // and extends to the end of the line (the next newline character or end of file). Everything after // on that line is ignored by the lexer.

// This is a line comment
let x = 42  // This is an inline comment

Line comments may appear on their own line or at the end of a line containing code.

Block Comments

BlockComment/* Character* */

A block comment begins with /* and ends with the next occurrence of */. Block comments may span multiple lines.

/* This is a block comment */

/*
  This block comment
  spans multiple lines.
*/

let x = /* inline block comment */ 42

Block comments do not nest. The first */ encountered after a /* ends the comment, regardless of any intervening /* sequences:

/* outer /* inner */ this is NOT inside the comment */

In the example above, the comment ends at the first */, and the text this is NOT inside the comment */ would be parsed as code (and likely produce a syntax error).

Doc Comments

Forge does not currently have a dedicated doc comment syntax (such as /// or /** */). Documentation is written using regular line comments or block comments by convention.

Comments in String Literals

Comment sequences (// and /* */) within string literals are part of the string content and are not treated as comments:

let url = "https://example.com"   // The // in the string is not a comment
let msg = "use /* carefully */"    // The /* */ in the string is not a comment

Placement

Comments may appear anywhere that whitespace is permitted. They cannot appear inside tokens (e.g., in the middle of an identifier or numeric literal).

Types

This chapter describes the type system of the Forge programming language.

Overview

Forge is dynamically typed at runtime: variables do not have fixed types, and any variable may hold a value of any type at any point during execution. However, Forge supports optional type annotations (gradual typing) on variable declarations, function parameters, and return types. These annotations serve as documentation and enable the optional type checker to detect certain errors before execution.

Every value in Forge belongs to exactly one of the following type categories:

CategoryTypes
PrimitiveInt, Float, String, Bool, Null
CollectionArray, Object
StructUser-defined via struct / thing
InterfaceUser-defined via interface / power
FunctionNamed functions, closures, lambdas
Algebraic (ADT)User-defined via type Name = Variant | ...
ResultOk(value), Err(message)
OptionSome(value), None

Type Annotations

Type annotations use a colon after the name, followed by the type:

let name: String = "Alice"
let age: Int = 30
let score: Float = 98.5
let active: Bool = true

Function parameters and return types may also be annotated:

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

When annotations are omitted, types are inferred from the assigned values. Annotations are always optional.

Type Inspection at Runtime

The built-in typeof() function (aliased as type()) returns a string describing the runtime type of a value:

say typeof(42)                 // Int
say typeof(3.14)               // Float
say typeof("hello")            // String
say typeof(true)               // Bool
say typeof(null)               // Null
say typeof([1, 2, 3])          // Array
say typeof({ name: "Alice" })  // Object

For struct instances, typeof() returns the struct name (e.g., "Person").

Truthiness

When a value is used in a boolean context (such as an if condition), Forge evaluates it as “truthy” or “falsy” according to the following rules:

ValueTruthy?
falseFalsy
nullFalsy
0 (integer zero)Falsy
0.0 (float zero)Falsy
"" (empty string)Falsy
[] (empty array)Falsy
Everything elseTruthy

Subsections

The following subsections define each type category in detail:

Primitive Types

Forge has five primitive types. Primitive values are immutable and compared by value.

Int

The Int type represents a 64-bit signed integer. Its range is -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (i.e., the range of a Rust i64).

let x = 42
let y = -7
let z = 0
say typeof(x)  // Int

Integer arithmetic follows standard rules. Division between two integers produces an integer result (truncating toward zero):

say 7 / 2    // 3
say -7 / 2   // -3

Integer overflow behavior is implementation-defined. The interpreter wraps on overflow (Rust’s default for i64 in release mode).

Float

The Float type represents a 64-bit IEEE 754 double-precision floating-point number. This provides approximately 15-17 significant decimal digits of precision.

let pi = 3.14159
let temp = -0.5
let one = 1.0
say typeof(pi)  // Float

When an arithmetic operation involves both an Int and a Float, the integer is implicitly promoted to a float, and the result is a Float:

say 5 + 2.0    // 7.0
say 10 / 3.0   // 3.3333333333333335

Special float values (NaN, Infinity, -Infinity) may arise from operations like division by zero on floats, but there is no literal syntax for these values.

String

The String type represents an immutable sequence of UTF-8 encoded characters. Strings are created using double-quoted literals and support interpolation and escape sequences (see Literals).

let greeting = "Hello, World!"
let empty = ""
let multiline = """This is
a raw string."""
say typeof(greeting)  // String

Key Properties

  • Immutable. String operations always return new strings; the original is never modified.
  • UTF-8. All strings are valid UTF-8. The len() function returns the number of bytes, not Unicode code points.
  • Interpolation. Double-quoted strings support {expr} interpolation. Raw strings ("""...""") do not.
  • Concatenation. The + operator concatenates two strings.
let name = "Forge"
let version = 3
say "Welcome to {name} v{version}!"  // Welcome to Forge v3!
say "Hello" + ", " + "World!"         // Hello, World!

String Comparison

Strings are compared lexicographically (byte-by-byte) using the standard comparison operators:

say "apple" < "banana"   // true
say "hello" == "hello"   // true

Bool

The Bool type has exactly two values: true and false.

let active = true
let deleted = false
say typeof(active)  // Bool

Boolean values result from comparison operators (==, !=, <, >, <=, >=) and logical operators (&&, ||, !). They are the natural type for if conditions, while conditions, and other control flow predicates.

Logical Operations

ExpressionResult
true && truetrue
true && falsefalse
false || truetrue
!truefalse

Both && and || use short-circuit evaluation.

Null

The Null type has exactly one value: null. It represents the absence of a meaningful value.

let nothing = null
say typeof(nothing)  // Null

null is returned by functions that have no explicit return statement. It is falsy in boolean contexts.

Null vs. None

Forge distinguishes between null and None:

  • null is a bare value representing “no value.” It is a primitive.
  • None is a variant of the Option type representing “intentionally absent.” It is a wrapper.

In practice, null appears in dynamic code and untyped contexts, while None is used in the Option/Result error-handling pattern. See Option and Result for details.

Type Comparison Summary

TypeSizeDefault ValueFalsy ValuesMutable
Int64 bits0No
Float64 bits0.0No
StringVariable""No
Bool1 bitfalseNo
Null0 bitsnullnullNo

All primitive types are compared by value. Two integers with the same numeric value are equal regardless of how they were computed.

Collection Types

Forge has two built-in collection types: Array and Object. Both are mutable, heap-allocated, and compared by reference for identity but by value for equality.

Array

An array is an ordered, heterogeneous, 0-indexed sequence of values. Elements may be of any type, including other arrays and objects.

Creation

Arrays are created with square bracket literals:

let empty = []
let nums = [1, 2, 3]
let mixed = [1, "two", true, null]
let nested = [[1, 2], [3, 4]]

Indexing

Elements are accessed by zero-based integer index using bracket notation:

let fruits = ["apple", "banana", "cherry"]
say fruits[0]   // apple
say fruits[1]   // banana
say fruits[2]   // cherry

Negative indices count from the end of the array:

say fruits[-1]  // cherry

Accessing an out-of-bounds index produces a runtime error.

Mutation

Arrays are mutable. Elements can be replaced by index assignment:

let mut items = [1, 2, 3]
items[0] = 10
say items  // [10, 2, 3]

The push() built-in appends an element:

let mut items = [1, 2]
push(items, 3)
say items  // [1, 2, 3]

The pop() built-in removes and returns the last element:

let mut items = [1, 2, 3]
let last = pop(items)
say last   // 3
say items  // [1, 2]

Length

The len() built-in returns the number of elements:

say len([1, 2, 3])  // 3
say len([])          // 0

Spread

The spread operator ... expands an array within another array literal:

let a = [1, 2, 3]
let b = [...a, 4, 5]
say b  // [1, 2, 3, 4, 5]

Iteration

Arrays are iterable with for/in:

for item in [10, 20, 30] {
    say item
}

With enumerate() for index-value pairs:

for i, item in enumerate(["a", "b", "c"]) {
    say "{i}: {item}"
}

Functional Operations

Arrays support map, filter, reduce, sort, reverse, find, flat_map, any, all, and other functional built-ins:

let nums = [1, 2, 3, 4, 5]
let doubled = map(nums, fn(x) { return x * 2 })
let evens = filter(nums, fn(x) { return x % 2 == 0 })
let sum = reduce(nums, 0, fn(acc, x) { return acc + x })

Truthiness

An empty array [] is falsy. All non-empty arrays are truthy.

Object

An object is an insertion-ordered map from string keys to arbitrary values. Objects are Forge’s general-purpose key-value data structure.

Creation

Objects are created with curly brace literals:

let empty = {}
let user = { name: "Alice", age: 30 }
let config = {
    host: "localhost",
    port: 8080,
    debug: false,
}

Keys are written as bare identifiers in the literal syntax. At runtime, they are strings.

Field Access

Fields are accessed with dot notation or bracket notation:

let user = { name: "Alice", age: 30 }
say user.name       // Alice
say user["age"]     // 30

Dot notation requires the field name to be a valid identifier. Bracket notation accepts any string expression, making it suitable for dynamic keys:

let key = "name"
say user[key]  // Alice

Mutation

Objects are mutable. Fields can be added, updated, or accessed dynamically:

let mut obj = { x: 1 }
obj.y = 2
obj.x = 10
say obj  // { x: 10, y: 2 }

Key Operations

FunctionDescription
keys(obj)Returns array of keys
values(obj)Returns array of values
entries(obj)Returns array of [key, value] pairs
has_key(obj, k)Returns true if key exists
len(obj)Returns number of key-value pairs
merge(a, b)Returns new object merging b into a
pick(obj, ks)Returns object with only specified keys
omit(obj, ks)Returns object without specified keys

Spread

The spread operator expands an object within another object literal:

let base = { x: 1, y: 2 }
let ext = { ...base, z: 3 }
say ext  // { x: 1, y: 2, z: 3 }

When keys conflict, later values overwrite earlier ones:

let a = { x: 1, y: 2 }
let b = { ...a, x: 10 }
say b  // { x: 10, y: 2 }

Iteration

Objects are iterable. A for/in loop over an object yields [key, value] pairs in insertion order:

let user = { name: "Alice", age: 30 }
for key, value in user {
    say "{key}: {value}"
}

Truthiness

An empty object {} is truthy (unlike empty arrays, which are falsy). All objects, including empty ones, are truthy.

Objects vs. Structs

Plain objects are untyped: any object can have any set of keys. Structs (defined with struct or thing) provide named types with declared fields, type annotations, default values, and methods. Under the hood, struct instances are objects with a __type__ field. See Struct Types for details.

Struct Types

A struct defines a named data type with declared fields, optional type annotations, and optional default values. Structs are the primary mechanism for defining domain-specific types in Forge.

Definition

Structs are defined with the struct keyword (classic syntax) or the thing keyword (natural syntax). Both forms are equivalent.

StructDef → ( struct | thing ) Identifier { FieldList }

FieldList → ( Field ( , Field )* ,? )?

FieldIdentifier : TypeAnnotation ( = Expression )? | has Identifier : TypeAnnotation

// Classic syntax
struct Point {
    x: Int,
    y: Int
}

// Natural syntax — identical result
thing Point {
    x: Int,
    y: Int
}

Field type annotations are part of the struct definition syntax. Each field has a name, a colon, and a type name.

Default Values

Fields may have default values. If a field with a default is omitted during construction, the default value is used:

thing Config {
    host: String = "localhost",
    port: Int = 8080,
    debug: Bool = false
}

set cfg to craft Config {}
say cfg.host   // localhost
say cfg.port   // 8080
say cfg.debug  // false

Fields without defaults are required — omitting them during construction produces a runtime error.

set prod to craft Config { host: "api.example.com", port: 443 }
say prod.host   // api.example.com
say prod.debug  // false (default)

Construction

There are two ways to create a struct instance:

Direct Construction (Classic)

Use the type name followed by a field initializer block:

let p = Point { x: 3, y: 4 }

Craft Construction (Natural)

Use the craft keyword:

set p to craft Point { x: 3, y: 4 }

Both forms produce the same value: an object with the declared fields plus a __type__ field set to the struct name.

Field Access

Fields are accessed with dot notation:

let p = Point { x: 3, y: 4 }
say p.x  // 3
say p.y  // 4

The __type__ Field

Every struct instance has an internal __type__ field containing the struct name as a string. This field is set automatically during construction and is used by the runtime for method dispatch, interface satisfaction checking, and typeof():

let p = Point { x: 3, y: 4 }
say typeof(p)  // Point

Methods

Methods are attached to a struct using give (natural) or impl (classic) blocks. See Method Blocks for full details.

give Point {
    define distance(it) {
        return math.sqrt(it.x * it.x + it.y * it.y)
    }
}

let p = Point { x: 3, y: 4 }
say p.distance()  // 5.0

The first parameter it is the receiver. When p.distance() is called, p is automatically bound to it.

Static Methods

If a method’s first parameter is not it, it is a static method called on the type rather than on an instance:

give Person {
    define infant(name) {
        return craft Person { name: name, age: 0 }
    }
}

set baby to Person.infant("Bob")

Multiple give Blocks

Multiple give blocks for the same type are permitted. Methods accumulate across all blocks:

give Person {
    define greet(it) {
        return "Hi, I'm " + it.name
    }
}

give Person {
    define birthday(it) {
        return craft Person { name: it.name, age: it.age + 1 }
    }
}

Composition with has

The has keyword inside a struct body embeds one type within another, enabling field and method delegation:

thing Address {
    street: String,
    city: String
}

thing Employee {
    name: String,
    has addr: Address
}

The has keyword provides two delegation mechanisms:

  1. Field delegation. Accessing a field that does not exist on the outer type delegates to the embedded type. emp.city resolves to emp.addr.city.

  2. Method delegation. Calling a method that does not exist on the outer type delegates to the embedded type. emp.full() resolves to emp.addr.full().

give Address {
    define full(it) {
        return it.street + ", " + it.city
    }
}

set emp to craft Employee {
    name: "Charlie",
    addr: craft Address { street: "123 Main St", city: "Portland" }
}

say emp.city     // Portland (delegated)
say emp.full()   // 123 Main St, Portland (delegated)

The explicit path (emp.addr.city) also works and produces the same result.

Struct vs. Object

FeatureObjectStruct
Type identityNone (generic Object)Named (__type__ field)
Field declarationsNoYes, with type annotations
Default valuesNoYes
MethodsNoYes (via give/impl)
Interface satisfactionNoYes (via give...the power)
Construction{ key: value }Name { } or craft Name { }

Interface Types

An interface (or “power” in natural syntax) defines a contract: a set of method signatures that a type must provide. Interfaces enable polymorphism in Forge — any type that satisfies an interface’s contract can be used wherever that interface is expected.

Definition

Interfaces are defined with the interface keyword (classic) or the power keyword (natural).

InterfaceDef → ( interface | power ) Identifier { MethodSignature* }

MethodSignaturefn Identifier ( ParamList? ) ( -> Type )?

// Classic syntax
interface Describable {
    fn describe() -> String
}

// Natural syntax — identical result
power Describable {
    fn describe() -> String
}

An interface body contains one or more method signatures. Each signature specifies the method name, parameter types, and optional return type. No method body is provided — interfaces declare what methods must exist, not how they work.

Implementing an Interface

A type satisfies an interface by providing implementations of all required methods. This is done using give ... the power ... (natural) or impl ... for ... (classic).

thing Person {
    name: String,
    age: Int
}

// Natural syntax
give Person the power Describable {
    define describe(it) {
        return it.name + " (" + str(it.age) + ")"
    }
}

// Classic syntax — identical result
impl Describable for Person {
    fn describe(it) {
        return it.name + " (" + str(it.age) + ")"
    }
}

The implementation block must provide a method for every signature in the interface. Missing methods produce a compile-time (definition-time) error.

Checking Satisfaction

The satisfies() built-in function checks whether a value’s type satisfies a given interface at runtime:

set alice to craft Person { name: "Alice", age: 30 }
say satisfies(alice, Describable)   // true

If a type has not implemented the interface, satisfies() returns false:

thing Robot {
    id: Int
}

set r to craft Robot { id: 1 }
say satisfies(r, Describable)   // false

Multiple Interfaces

A single type may implement multiple interfaces:

power Describable {
    fn describe() -> String
}

power Vocal {
    fn speak() -> String
}

thing Animal {
    species: String,
    sound: String
}

give Animal the power Describable {
    define describe(it) {
        return it.species + " that says " + it.sound
    }
}

give Animal the power Vocal {
    define speak(it) {
        return it.sound + "! " + it.sound + "!"
    }
}

Each interface implementation is provided in a separate give...the power block. A type accumulates all its interface implementations.

No Default Implementations

In the current version of Forge, interfaces cannot provide default method implementations. Every method in an interface must be explicitly implemented by each type that satisfies it.

Interface Inheritance

Interface inheritance (one interface extending another) is not supported in the current version. Each interface is independent.

Structural vs. Nominal

Forge uses a nominal approach to interface satisfaction: a type satisfies an interface only if it has been explicitly declared to do so via a give...the power or impl...for block. Simply having methods with matching names and signatures is not sufficient.

However, the satisfies() built-in performs a structural check at runtime — it verifies that the required methods actually exist on the value. This means satisfaction is ultimately determined by the presence of the declared methods, but the declaration is required to register the type-interface relationship.

Function Types

Functions are first-class values in Forge. They can be stored in variables, passed as arguments, returned from other functions, and placed in data structures.

Named Functions

A named function is declared with fn (classic) or define (natural):

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

define multiply(a, b) {
    return a * b
}

Named functions are hoisted within their scope — they can be called before their textual definition in the source file.

Anonymous Functions (Closures)

An anonymous function is created with the fn keyword in expression position:

let double = fn(x) { return x * 2 }
say double(21)  // 42

Anonymous functions are also called closures because they capture variables from their enclosing scope.

Closure Semantics

Closures capture variables from the enclosing scope by reference. The closure retains access to the captured variables for its entire lifetime:

fn make_counter() {
    let mut count = 0
    return fn() {
        count = count + 1
        return count
    }
}

let counter = make_counter()
say counter()  // 1
say counter()  // 2
say counter()  // 3

Each call to make_counter() creates a new independent closure with its own count variable.

Factory Pattern

Closures are commonly used to create specialized functions:

fn make_adder(n) {
    return fn(x) {
        return x + n
    }
}

let add5 = make_adder(5)
let add10 = make_adder(10)
say add5(3)   // 8
say add10(3)  // 13

Functions as Arguments (Higher-Order Functions)

Functions can be passed as arguments to other functions:

fn apply(f, value) {
    return f(value)
}

fn square(x) { return x * x }
say apply(square, 7)  // 49

The built-in map, filter, reduce, and sort functions all accept function arguments:

let nums = [1, 2, 3, 4, 5]
let doubled = map(nums, fn(x) { return x * 2 })
say doubled  // [2, 4, 6, 8, 10]

Functions in Data Structures

Functions can be stored in arrays and objects:

fn add(a, b) { return a + b }
fn sub(a, b) { return a - b }

let ops = [add, sub]
say ops[0](10, 3)  // 13
say ops[1](10, 3)  // 7
let handlers = {
    greet: fn(name) { return "Hello, {name}!" },
    farewell: fn(name) { return "Goodbye, {name}!" }
}
say handlers.greet("World")  // Hello, World!

Type-Annotated Functions

Function parameters and return values may carry type annotations:

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

fn format_price(amount: Float) -> String {
    return "${amount}"
}

Annotations are optional and serve as documentation. The optional type checker can use them to report errors before execution.

Return Values

Functions return a value via the return statement. If no return is executed, the function returns null:

fn greet(name) {
    println("Hello, {name}!")
}

let result = greet("World")
say typeof(result)  // Null

A function may return early from any point:

fn classify(n) {
    if n < 0 { return "negative" }
    if n == 0 { return "zero" }
    return "positive"
}

Async Functions

Async functions are declared with async fn (classic) or forge (natural):

// Classic
async fn fetch_data() {
    let resp = await http.get("https://api.example.com/data")
    return resp
}

// Natural
forge fetch_data() {
    let resp = hold http.get("https://api.example.com/data")
    return resp
}

Async functions return a future that must be awaited with await (classic) or hold (natural). See Concurrency for details.

Function Equality

Functions are compared by reference identity, not by their code. Two function values are equal only if they refer to the same function object:

fn f() { return 1 }
let a = f
let b = f
say a == b  // true (same function object)

let c = fn() { return 1 }
say a == c  // false (different function objects)

Algebraic Data Types

Algebraic data types (ADTs) define a type as a fixed set of variants. Each variant may optionally carry data. ADTs enable exhaustive pattern matching — the compiler/runtime can verify that all variants are handled.

Definition

An ADT is defined with the type keyword, listing variants separated by |:

ADTDeftype Identifier = Variant ( | Variant )*

VariantIdentifier ( ( TypeList ) )?

TypeListType ( , Type )*

Unit Variants

Variants without data fields are called unit variants:

type Color = Red | Green | Blue

Unit variants are used as simple enumeration values:

set c to Red
say c  // Red

Data Variants

Variants may carry typed data fields:

type Shape = Circle(Float) | Rect(Float, Float)

Data variants are constructed by calling the variant name as a function:

set circle to Circle(5.0)
set rect to Rect(3.0, 4.0)
say circle  // Circle(5.0)
say rect    // Rect(3.0, 4.0)

Mixed Variants

An ADT may freely mix unit variants and data variants:

type Result = Ok(String) | Err(String) | Pending

Construction

Unit variants are referenced by name alone:

let color = Red

Data variants are constructed by calling the variant name with the appropriate arguments:

let shape = Circle(5.0)
let rect = Rect(3.0, 4.0)

The number and types of arguments must match the variant definition.

Pattern Matching

ADT values are destructured using match expressions. Each arm matches a variant and optionally binds its data fields to variables.

Basic Matching

type Color = Red | Green | Blue

let c = Red

match c {
    Red => say "Red!"
    Green => say "Green!"
    Blue => say "Blue!"
}

Destructuring Data Variants

Data fields are bound to named variables in the match arm:

type Shape = Circle(Float) | Rect(Float, Float)

define describe_shape(s) {
    match s {
        Circle(r) => {
            say "Circle with radius {r}, area = {3.14159 * r * r}"
        }
        Rect(w, h) => {
            say "Rectangle {w}x{h}, area = {w * h}"
        }
    }
}

describe_shape(Circle(5.0))
// Output: Circle with radius 5.0, area = 78.53975

describe_shape(Rect(3.0, 4.0))
// Output: Rectangle 3.0x4.0, area = 12.0

Match as Expression

match can be used as an expression that returns a value:

let area = match shape {
    Circle(r) => 3.14159 * r * r
    Rect(w, h) => w * h
}

Exhaustiveness

A match expression on an ADT should handle all variants. If a variant is missing, the runtime will produce an error when an unhandled variant is encountered.

type Color = Red | Green | Blue

// This handles all variants
match c {
    Red => say "Red!"
    Green => say "Green!"
    Blue => say "Blue!"
}

Wildcard Pattern

The _ pattern matches any value, serving as a catch-all:

match c {
    Red => say "It's red"
    _ => say "It's not red"
}

Built-in ADTs

Forge provides two built-in algebraic types:

These follow the same pattern matching conventions as user-defined ADTs:

let x = Some(42)

match x {
    Some(val) => say "Got: {val}"
    None => say "Nothing"
}

Scope

Variant constructors (e.g., Red, Circle, Some, Ok) are introduced into the scope where the type definition appears. For built-in types like Option and Result, the constructors are globally available.

Option and Result

Forge provides two built-in wrapper types for representing optional values and fallible operations: Option and Result. These types enable explicit, composable error handling without exceptions.

Option Type

The Option type represents a value that may or may not be present.

Variants

VariantMeaning
Some(value)A value is present
NoneNo value is present

Construction

let x = Some(42)
let y = None

Both Some and None are globally available constructors.

Inspection

FunctionDescriptionExample
is_some(v)Returns true if v is Some(...)is_some(Some(1)) = true
is_none(v)Returns true if v is Noneis_none(None) = true
let x = Some(42)
say is_some(x)  // true
say is_none(x)  // false

let y = None
say is_some(y)  // false
say is_none(y)  // true

Pattern Matching

Option values are destructured with match:

let value = Some(42)

match value {
    Some(v) => say "Got: {v}"
    None => say "Nothing"
}

Unwrapping

FunctionDescription
unwrap(v)Returns the inner value; crashes if None
unwrap_or(v, d)Returns the inner value, or d if None
let x = Some(42)
say unwrap(x)          // 42
say unwrap_or(x, 0)    // 42

let y = None
say unwrap_or(y, 0)    // 0
// unwrap(y) would crash with an error

Result Type

The Result type represents the outcome of an operation that may succeed or fail.

Variants

VariantMeaning
Ok(value)Operation succeeded with value
Err(message)Operation failed with message

Construction

let success = Ok(42)
let failure = Err("something went wrong")

Result constructors accept both cases: Ok(42) and ok(42) are equivalent, as are Err("msg") and err("msg").

Inspection

FunctionDescriptionExample
is_ok(v)Returns true if v is Ok(...)is_ok(Ok(1)) = true
is_err(v)Returns true if v is Err(...)is_err(Err("x")) = true
let result = Ok(42)
say is_ok(result)   // true
say is_err(result)  // false

Pattern Matching

Result values are destructured with match:

fn parse_number(s) {
    let n = int(s)
    if n == null {
        return Err("invalid number: {s}")
    }
    return Ok(n)
}

match parse_number("42") {
    Ok(n) => say "Parsed: {n}"
    Err(msg) => say "Error: {msg}"
}

Unwrapping

FunctionDescription
unwrap(v)Returns the inner value; crashes if Err
unwrap_or(v, d)Returns the inner value, or d if Err
let result = Ok(42)
say unwrap(result)          // 42
say unwrap_or(result, 0)    // 42

let err = Err("failed")
say unwrap_or(err, 0)       // 0

The ? Operator

The ? postfix operator provides concise error propagation. When applied to a Result value:

  • If the value is Ok(v), the ? unwraps it to v and execution continues.
  • If the value is Err(e), the enclosing function immediately returns Err(e).
fn read_config(path) {
    if !fs.exists(path) {
        return Err("config file not found")
    }
    return Ok(fs.read(path))
}

fn start_server() {
    let config = read_config("server.toml")?
    say "Starting with config: {config}"
    return Ok(true)
}

match start_server() {
    Ok(_) => say "Server started"
    Err(msg) => say "Failed: {msg}"
}

The ? operator can only be used inside functions that return Result. It is syntactic sugar for:

let result = read_config("server.toml")
if is_err(result) {
    return result
}
let config = unwrap(result)

The must Keyword

The must keyword is an assertion on Result values. It unwraps an Ok value or crashes the program with a clear error message on Err:

let config = must read_config("server.toml")

Use must for errors that are truly unrecoverable — situations where the program cannot meaningfully continue (e.g., missing configuration, failed database connection).

The safe Block

The safe block catches any errors within its body and returns null instead of crashing:

safe {
    let result = risky_operation()
    say result
}
// If risky_operation() fails, execution continues here

safe is a statement-level construct. It does not return a value and cannot be used as an expression.

Idiomatic Error Handling

The recommended patterns for error handling in Forge, in order of preference:

  1. ? for propagation. Pass errors up the call stack to a centralized handler.
  2. match for handling. Explicitly handle both Ok and Err at the appropriate level.
  3. unwrap_or for defaults. Provide a fallback when an error is acceptable.
  4. must for fatal errors. Crash with a clear message when recovery is impossible.
  5. safe for silencing. Suppress errors only when the operation is truly optional.

Type Conversions

Forge is dynamically typed and does not perform implicit type coercion between incompatible types (with the exception of Int to Float promotion in mixed arithmetic). Explicit conversion functions are provided for converting between types.

Conversion Functions

str(value) — Convert to String

Converts any value to its string representation.

say str(42)       // "42"
say str(3.14)     // "3.14"
say str(true)     // "true"
say str(null)     // "null"
say str([1, 2])   // "[1, 2]"

str() never fails. Every Forge value has a string representation.

int(value) — Convert to Int

Converts a value to a 64-bit signed integer.

say int("42")     // 42
say int("100")    // 100
say int(3.14)     // 3 (truncates toward zero)
say int(true)     // 1
say int(false)    // 0

If the input string cannot be parsed as an integer, int() produces a runtime error. Always validate user input before converting.

Input TypeBehavior
StringParses decimal integer; error if invalid
FloatTruncates toward zero
Booltrue = 1, false = 0
IntReturns the value unchanged
OtherRuntime error

float(value) — Convert to Float

Converts a value to a 64-bit floating-point number.

say float("3.14")  // 3.14
say float(42)      // 42.0
say float(true)    // 1.0
say float(false)   // 0.0

If the input string cannot be parsed as a float, float() produces a runtime error.

Input TypeBehavior
StringParses decimal float; error if invalid
IntPromotes to float (lossless for most values)
Booltrue = 1.0, false = 0.0
FloatReturns the value unchanged
OtherRuntime error

Type Inspection Functions

typeof(value) — Get Type Name

Returns a string describing the runtime type of a value.

say typeof(42)                  // Int
say typeof(3.14)                // Float
say typeof("hello")             // String
say typeof(true)                // Bool
say typeof(null)                // Null
say typeof([1, 2, 3])           // Array
say typeof({ name: "Alice" })   // Object

For struct instances, typeof() returns the struct name:

thing Point { x: Int, y: Int }
let p = Point { x: 1, y: 2 }
say typeof(p)   // Point

For functions:

fn f() { return 1 }
say typeof(f)   // Function

type(value) — Alias for typeof

The type() function is an alias for typeof(). Both return identical results:

let value = 3.14
if type(value) == "Float" {
    say "It's a float"
}

Implicit Conversions

Forge performs very few implicit conversions:

Int-to-Float Promotion

When an arithmetic operator has one Int operand and one Float operand, the integer is implicitly promoted to a float. The result is a Float:

say 5 + 2.0    // 7.0 (Int promoted to Float)
say 10 / 3.0   // 3.3333333333333335

String Interpolation

Inside string interpolation ({expr}), the expression result is implicitly converted to a string using the same logic as str():

let n = 42
say "The answer is {n}"  // "The answer is 42"

Truthiness

When a value is used in a boolean context (e.g., if condition), it is evaluated for truthiness (see Types). This is not a type conversion — the value itself is not changed. It is a contextual interpretation.

No Other Implicit Coercion

Operations between incompatible types (e.g., adding a string and an integer) produce a runtime error. Explicit conversion is required:

// Error: cannot add String and Int
// say "age: " + 30

// Correct: convert explicitly
say "age: " + str(30)

Expressions

An expression is a syntactic construct that evaluates to a value. Every expression in Forge has a type and produces a result when evaluated.

Forge distinguishes expressions from statements: expressions produce values, statements produce effects. An expression can appear anywhere a value is expected – as the right-hand side of a variable binding, as a function argument, or as the body of a when arm.

Expression Categories

Literal Expressions

Literal expressions produce values directly from source text.

LiteralExampleValue Type
Integer42int
Float3.14float
String"hello"string
Booleantrue, falsebool
Nullnullnull
Array[1, 2, 3]array
Object{ name: "Forge", version: 1 }object

String literals support interpolation with embedded expressions.

Identifier Expressions

An identifier evaluates to the value bound to that name in the current scope.

let x = 10
say x       // evaluates to 10

If the identifier is not in scope, evaluation produces a runtime error.

Arithmetic Expressions

Binary operations on numeric values: +, -, *, /, %. The + operator also performs string concatenation.

See Arithmetic.

Comparison and Logical Expressions

Comparison operators (==, !=, <, >, <=, >=) produce boolean values. Logical operators (and/&&, or/||, not/!) combine boolean expressions with short-circuit evaluation.

See Comparison and Logical.

Field Access Expressions

Dot notation accesses fields on objects and struct instances. Embedded fields are resolved through delegation.

let user = { name: "Alice", age: 30 }
say user.name   // "Alice"

See Field Access.

Index Expressions

Bracket notation accesses elements by position in arrays or by key in objects.

let items = [10, 20, 30]
say items[0]    // 10

let obj = { x: 1 }
say obj["x"]    // 1

Method Call Expressions

Method calls use dot notation followed by a function call. Forge resolves methods through a multi-step dispatch chain: object fields, type method tables, embedded field delegation, then known built-in methods.

let names = ["Charlie", "Alice", "Bob"]
say names.sort()    // ["Alice", "Bob", "Charlie"]

See Method Calls.

Function Call Expressions

A function call evaluates a callable expression and applies it to a list of argument expressions.

fn square(n) { n * n }
say square(5)   // 25

Closures and Lambdas

Anonymous functions that capture their enclosing environment.

let double = fn(x) { x * 2 }
say double(5)   // 10

See Closures and Lambdas.

When Guard Expressions

Pattern-matching on a scrutinee value using comparison operators.

let label = when age {
    < 13 -> "child",
    < 18 -> "teen",
    else -> "adult"
}

See When Guards.

Match Expressions

Structural pattern matching with destructuring of algebraic data types.

match shape {
    Circle(r) => say "radius: {r}",
    Rect(w, h) => say "area: {w * h}",
    _ => say "unknown"
}

See Match Expressions.

String Interpolation Expressions

Double-quoted strings with embedded expressions in {...} delimiters.

let name = "world"
say "hello, {name}!"   // "hello, world!"

See String Interpolation.

Pipeline Expressions

The pipe operator |> threads a value through a chain of function calls.

[1, 2, 3, 4, 5]
    |> filter(fn(x) { x > 2 })
    |> map(fn(x) { x * 10 })

Unary Expressions

Unary operators: - (numeric negation) and not/! (logical negation).

let x = -5
let flag = not true

Try Expressions

The ? operator propagates errors from Result values. If the value is Err, evaluation returns early from the enclosing function.

let data = fs.read("config.json")?

Must Expressions

The must keyword unwraps a Result or crashes with a descriptive error message.

let data = must fs.read("config.json")

Struct Initialization Expressions

Creates an instance of a named struct type.

thing Point { x: int, y: int }
let p = Point { x: 10, y: 20 }

Spread Expressions

The ... operator expands an array or object in a literal context.

let base = [1, 2, 3]
let extended = [...base, 4, 5]

Expression Evaluation Order

Forge evaluates expressions left to right. In a binary expression a + b, a is evaluated before b. In a function call f(x, y), f is evaluated first, then x, then y.

Logical operators and/&& and or/|| use short-circuit evaluation: the right operand is not evaluated if the left operand determines the result. See Comparison and Logical for details.

Arithmetic Expressions

Arithmetic expressions perform numeric computations and string concatenation.

Operators

OperatorNameOperand TypesResult Type
+Additionint + intint
+Additionfloat + floatfloat
+Additionint + float or float + intfloat
+Concatenationstring + stringstring
-Subtractionint - intint
-Subtractionfloat - floatfloat
-Subtractionint - float or float - intfloat
*Multiplicationint * intint
*Multiplicationfloat * floatfloat
*Multiplicationint * float or float - intfloat
/Divisionint / intint (truncated)
/Divisionfloat / floatfloat
/Divisionint / float or float / intfloat
%Moduloint % intint
%Modulofloat % floatfloat

Integer Arithmetic

Integer arithmetic uses 64-bit signed integers (i64). Operations that overflow follow Rust’s default behavior (panic in debug, wrap in release).

let a = 10 + 3      // 13
let b = 10 - 3      // 7
let c = 10 * 3      // 30
let d = 10 / 3      // 3 (truncated toward zero)
let e = 10 % 3      // 1

Integer Division

Integer division truncates toward zero. The result is always an integer when both operands are integers.

say 7 / 2       // 3
say -7 / 2      // -3
say 7 / -2      // -3

Float Arithmetic

Float arithmetic uses 64-bit IEEE 754 double-precision floating-point numbers (f64).

let a = 3.14 + 2.0     // 5.14
let b = 10.0 / 3.0     // 3.3333333333333335
let c = 2.5 % 1.0      // 0.5

Mixed Arithmetic

When one operand is int and the other is float, the integer is promoted to a float before the operation. The result is always float.

say 5 + 2.0      // 7.0
say 10 / 3.0     // 3.3333333333333335
say 3 * 1.5      // 4.5

String Concatenation

The + operator concatenates two strings. If one operand is a string and the other is not, the non-string operand is converted to its string representation.

say "hello" + " " + "world"    // "hello world"
say "count: " + str(42)        // "count: 42"

For embedding expressions in strings, prefer string interpolation:

say "count: {42}"              // "count: 42"

Unary Negation

The - prefix operator negates a numeric value.

let x = 5
say -x          // -5
say -3.14       // -3.14

Operator Precedence

Arithmetic operators follow standard mathematical precedence:

  1. Unary - (highest)
  2. *, /, %
  3. +, - (lowest among arithmetic)

Parentheses override precedence:

say 2 + 3 * 4       // 14
say (2 + 3) * 4     // 20

See the Operator Precedence appendix for the full precedence table including all operator categories.

Division by Zero

Dividing by zero produces a runtime error:

say 10 / 0      // runtime error: division by zero

Float division by zero follows IEEE 754 rules and may produce infinity or NaN.

Comparison and Logical Expressions

Comparison operators produce boolean values by comparing two operands. Logical operators combine boolean expressions using short-circuit evaluation.

Comparison Operators

OperatorMeaningExample
==Equalx == y
!=Not equalx != y
<Less thanx < y
>Greater thanx > y
<=Less than or equalx <= y
>=Greater than or equalx >= y

Equality

The == operator tests structural equality. Two values are equal if they have the same type and the same content.

say 1 == 1              // true
say "abc" == "abc"      // true
say [1, 2] == [1, 2]   // true
say null == null        // true
say 1 == 1.0            // true (numeric promotion)
say 1 == "1"            // false (different types)

Numeric Comparison

Integers and floats can be compared directly. When comparing an int with a float, the integer is promoted to float.

say 3 < 5           // true
say 3.14 > 2.71     // true
say 10 >= 10        // true
say 5 <= 3          // false
say 1 < 2.5         // true (int promoted to float)

String Comparison

Strings are compared lexicographically (by Unicode code points).

say "apple" < "banana"      // true
say "abc" == "abc"          // true
say "a" < "b"               // true

Null Comparison

Only null is equal to null. Comparing null with any other value using == yields false.

say null == null        // true
say null == 0           // false
say null == ""          // false
say null == false       // false

Logical Operators

Forge supports two syntactic forms for each logical operator. Both forms are equivalent.

ClassicNaturalMeaning
&&andLogical AND
||orLogical OR
!notLogical NOT

Logical AND

Returns true if both operands are truthy. Uses short-circuit evaluation: if the left operand is falsy, the right operand is not evaluated.

say true and true       // true
say true and false      // false
say false and true      // false (right side not evaluated)

Logical OR

Returns true if either operand is truthy. Uses short-circuit evaluation: if the left operand is truthy, the right operand is not evaluated.

say false or true       // true
say true or false       // true (right side not evaluated)
say false or false      // false

Logical NOT

Returns the boolean negation of the operand. Applies truthiness rules.

say not true        // false
say not false       // true
say !null           // true
say not 0           // false (0 is truthy in Forge)

Short-Circuit Evaluation

Short-circuit evaluation means the right operand of and/&& or or/|| is only evaluated when necessary.

// The function is never called because the left side is false
false and expensive_computation()

// The function is never called because the left side is true
true or expensive_computation()

This is significant when the right operand has side effects:

let x = null
// Safe: the right side is not evaluated when x is null
x != null and x.name == "Alice"

Truthiness Rules

Forge uses the following truthiness rules when a value appears in a boolean context (such as an if condition or a logical operator):

ValueTruthiness
falsefalsy
nullfalsy
Everything elsetruthy

Notably, the following values are truthy (unlike some other languages):

  • 0 (zero)
  • "" (empty string)
  • [] (empty array)
  • {} (empty object)
if 0 {
    say "zero is truthy"    // this executes
}

if "" {
    say "empty string is truthy"    // this executes
}

if null {
    say "unreachable"
} otherwise {
    say "null is falsy"     // this executes
}

Operator Precedence

From highest to lowest precedence:

  1. not / ! (unary)
  2. <, >, <=, >=
  3. ==, !=
  4. and / &&
  5. or / ||
say true or false and false     // true (and binds tighter than or)
say not false and true          // true (not binds tightest)

See the Operator Precedence appendix for the full table.

String Interpolation

Double-quoted string literals in Forge support interpolation: embedding arbitrary expressions inside { and } delimiters. The expressions are evaluated at runtime and their results are converted to string representations and spliced into the surrounding text.

Syntax

" ... {expression} ... "

Any valid Forge expression may appear between the braces.

Basic Usage

let name = "Forge"
say "Hello, {name}!"           // "Hello, Forge!"

let x = 10
let y = 20
say "{x} + {y} = {x + y}"     // "10 + 20 = 30"

Arbitrary Expressions

The interpolated expression is not limited to identifiers. Any expression that produces a value is permitted, including function calls, arithmetic, method calls, and field access.

say "length: {len([1, 2, 3])}"             // "length: 3"
say "upper: {"hello".upper()}"             // "upper: HELLO"
say "sum: {1 + 2 + 3}"                     // "sum: 6"
say "type: {typeof(42)}"                    // "type: int"

Value Conversion

Interpolated values are converted to their string representation using the same rules as the str() builtin:

Value TypeString Representation
stringThe string itself
intDecimal representation (e.g., "42")
floatDecimal representation (e.g., "3.14")
bool"true" or "false"
null"null"
array"[1, 2, 3]"
object"{key: value, ...}"

Escape Sequences

The following escape sequences are recognized inside double-quoted strings:

EscapeCharacter
\nNewline
\tTab
\\Backslash
\{Literal { (suppresses interpolation)
\"Double quote

To include a literal { character without triggering interpolation, escape it:

say "use \{braces\} for interpolation"
// Output: use {braces} for interpolation

Nesting

Interpolated expressions may themselves contain string literals with interpolation, though this is discouraged for readability:

let items = ["a", "b", "c"]
say "result: {join(items, ", ")}"

Non-Interpolated Strings

Single-quoted strings do not support interpolation. Use single quotes when the string contains braces that should be treated literally:

say 'no {interpolation} here'
// Output: no {interpolation} here

Empty Interpolation

An empty interpolation {} is not valid and produces a parse error.

Implementation Notes

String interpolation is parsed into a StringInterp AST node containing a sequence of StringPart elements, each being either a Literal (raw text) or an Expr (an evaluated expression). At runtime, all parts are evaluated and concatenated to produce the final string value.

Field Access

The dot operator (.) accesses fields on objects and struct instances. Forge resolves field access through direct lookup followed by delegation through embedded fields.

Syntax

expression.identifier

The left operand is evaluated to produce an object. The identifier names the field to retrieve.

Object Field Access

Objects are unordered collections of key-value pairs. Dot notation retrieves a value by key.

let user = { name: "Alice", age: 30 }
say user.name       // "Alice"
say user.age        // 30

If the field does not exist on the object, a runtime error is produced:

let user = { name: "Alice" }
say user.email      // runtime error: no field 'email' on object

Struct Field Access

Struct instances (created from thing/struct definitions) are objects with a __type__ field. Field access works identically.

thing Point { x: int, y: int }
let p = Point { x: 10, y: 20 }
say p.x     // 10
say p.y     // 20

Embedded Field Delegation

When a struct uses has to embed another type, field access delegates to the embedded field if the field is not found directly on the outer object.

thing Base { id: int }
thing Extended {
    has base: Base
    name: string
}

let e = Extended { base: Base { id: 1 }, name: "test" }
say e.name      // "test" (direct)
say e.id        // 1 (delegated to e.base.id)

Resolution Order

Field access resolves in this order:

  1. Direct field lookup: Check if the object has a field with the given name.
  2. Embedded field delegation: If the object has a __type__, check each embedded field’s sub-object for the field name. Embedded fields are checked in definition order.

If neither step finds the field, a runtime error is produced.

Chaining

Field access expressions can be chained to traverse nested structures.

let config = {
    server: {
        host: "localhost",
        port: 8080
    }
}
say config.server.host      // "localhost"
say config.server.port      // 8080

Built-in Field Access on Primitives

Certain built-in fields are available on primitive types:

Strings

FieldTypeDescription
.lenintNumber of bytes
.upperstringUppercase copy
.lowerstringLowercase copy
.trimstringWhitespace-trimmed copy
let s = "  Hello  "
say s.len       // 9
say s.upper     // "  HELLO  "
say s.trim      // "Hello"

Arrays

FieldTypeDescription
.lenintNumber of elements
let items = [1, 2, 3]
say items.len   // 3

Index-Based Access

For dynamic key access, use bracket notation instead of dot notation:

let obj = { name: "Alice" }
let key = "name"
say obj[key]        // "Alice"

Bracket notation works on both objects (with string keys) and arrays (with integer indices).

Method Calls

A method call uses dot notation to invoke a function on a receiver value. Forge resolves method calls through a multi-step dispatch process.

Syntax

expression.method(arguments)

The left operand (the receiver) is evaluated first, then the method is resolved, then arguments are evaluated left to right.

Method Dispatch

When evaluating obj.method(args), the interpreter follows this resolution order:

1. Object Field Lookup

If the receiver is an object and has a field named method whose value is callable (a function or closure), that field is invoked directly.

let obj = {
    greet: fn(name) { "hello, {name}" }
}
say obj.greet("world")      // "hello, world"

2. Static Method Lookup

If the receiver is a struct type reference (not an instance), static methods registered via give/impl blocks are checked.

thing Counter { value: int }

give Counter {
    fn new() {
        Counter { value: 0 }
    }
}

let c = Counter.new()   // static method call

3. Instance Method Lookup (method_tables)

If the receiver is a typed object (has a __type__ field), the interpreter looks up the type name in the global method table. Methods registered via give/impl blocks are found here. The receiver is automatically passed as the first argument (self).

thing Circle { radius: float }

give Circle {
    fn area(self) {
        3.14159 * self.radius * self.radius
    }
}

let c = Circle { radius: 5.0 }
say c.area()    // 78.53975

4. Embedded Field Delegation

If the receiver is a typed object and no method is found in step 3, the interpreter checks embedded fields (declared with has). For each embedded field, the interpreter looks up the embedded type’s method table. If a match is found, the embedded sub-object is passed as self instead of the outer object.

thing Animal { name: string }
give Animal {
    fn speak(self) { "{self.name} speaks" }
}

thing Pet {
    has animal: Animal
    owner: string
}

let p = Pet { animal: Animal { name: "Rex" }, owner: "Alice" }
say p.speak()   // "Rex speaks" (delegated to Animal.speak)

5. Built-in String Methods

Strings have a small set of built-in methods that are resolved directly without going through the method table:

MethodReturn TypeDescription
.upper()stringUppercase copy
.lower()stringLowercase copy
.trim()stringTrimmed copy
.len()intByte length
.chars()arrayArray of single-character strings
say "hello".upper()     // "HELLO"
say "  hi  ".trim()     // "hi"
say "abc".chars()       // ["a", "b", "c"]

6. Known Built-in Methods

If none of the above steps match, Forge checks a set of known built-in method names. If the method name matches, the call is rewritten as a function call with the receiver prepended to the argument list: obj.method(args) becomes method(obj, args).

This allows calling built-in functions with method syntax:

let nums = [3, 1, 2]
say nums.sort()             // [1, 2, 3]
say nums.map(fn(x) { x * 2 })  // [6, 2, 4]
say nums.filter(fn(x) { x > 1 })   // [3, 2]

let text = "hello world"
say text.split(" ")        // ["hello", "world"]
say text.starts_with("hello")  // true

The full list of known built-in methods includes: map, filter, reduce, sort, reverse, push, pop, len, contains, keys, values, enumerate, split, join, replace, find, flat_map, has_key, get, pick, omit, merge, entries, from_entries, starts_with, ends_with, upper, lower, trim, substring, index_of, last_index_of, pad_start, pad_end, capitalize, title, repeat_str, count, sum, min_of, max_of, any, all, unique, zip, flatten, group_by, chunk, slice, slugify, snake_case, camel_case, sample, shuffle, partition, diff.

Self Parameter

Methods defined in give/impl blocks receive the receiver as their first argument, conventionally named self. This parameter must be declared explicitly.

thing Rect { w: int, h: int }

give Rect {
    fn area(self) {
        self.w * self.h
    }
    fn scale(self, factor) {
        Rect { w: self.w * factor, h: self.h * factor }
    }
}

Method Chaining

Method calls can be chained. Each call in the chain returns a value that becomes the receiver for the next call.

let result = [5, 2, 8, 1, 9]
    .sort()
    .filter(fn(x) { x > 3 })
    .map(fn(x) { x * 10 })
// result: [50, 80, 90]

Resolution Failure

If method resolution exhausts all steps without finding a match, a runtime error is produced:

let x = 42
say x.nonexistent()     // runtime error: unknown method 'nonexistent'

Closures and Lambdas

A closure (also called a lambda) is an anonymous function expression that captures variables from its enclosing scope. Closures are first-class values: they can be stored in variables, passed as arguments, and returned from functions.

Syntax

fn(parameters) { body }

The keyword fn introduces a closure. Parameters are comma-separated identifiers enclosed in parentheses. The body is a block of statements.

let double = fn(x) { x * 2 }
say double(5)       // 10

No-Parameter Closures

Closures with no parameters use empty parentheses:

let greet = fn() { "hello" }
say greet()         // "hello"

Implicit Return

The last expression in a closure body is its return value. An explicit return statement is also permitted but rarely needed.

let add = fn(a, b) { a + b }       // implicit return
say add(3, 4)                       // 7

let abs = fn(x) {
    if x < 0 {
        return -x                   // explicit return
    }
    x
}

Capture Semantics

Closures capture variables from their enclosing scope by reference. Changes to captured variables are visible inside the closure, and mutations inside the closure affect the outer scope.

let mut count = 0
let increment = fn() {
    count = count + 1
    count
}
say increment()     // 1
say increment()     // 2
say count           // 2

Capture at Definition Time

The closure captures a reference to the variable’s binding, not its current value. The variable is resolved at the time the closure is called, not when it is defined.

let mut x = 10
let get_x = fn() { x }
say get_x()         // 10

x = 20
say get_x()         // 20

Higher-Order Functions

Closures enable higher-order programming patterns. A higher-order function either takes a function as an argument or returns one.

Closures as Arguments

Many built-in functions accept closures:

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

let evens = filter(nums, fn(x) { x % 2 == 0 })
say evens       // [2, 4]

let doubled = map(nums, fn(x) { x * 2 })
say doubled     // [2, 4, 6, 8, 10]

let total = reduce(nums, 0, fn(acc, x) { acc + x })
say total       // 15

Returning Closures

Functions can return closures, creating function factories:

fn make_adder(n) {
    fn(x) { x + n }
}

let add5 = make_adder(5)
say add5(10)    // 15
say add5(20)    // 25

Closures in Method Syntax

Closures integrate naturally with method-style calls:

let names = ["Charlie", "Alice", "Bob"]
let sorted = names.sort(fn(a, b) { a < b })
say sorted      // ["Alice", "Bob", "Charlie"]

Multi-Statement Bodies

Closure bodies can contain multiple statements. The last expression is the return value.

let process = fn(items) {
    let filtered = filter(items, fn(x) { x > 0 })
    let doubled = map(filtered, fn(x) { x * 2 })
    doubled
}
say process([-1, 2, -3, 4])    // [4, 8]

Closures vs Named Functions

Closures and named functions (fn name(...) { } / define name(...) { }) differ in two ways:

  1. Naming: Named functions are bound to a name in the current scope. Closures are anonymous and must be assigned to a variable explicitly.
  2. Hoisting: Named functions are available throughout their defining scope. Closures are only available after the variable assignment that holds them.

Both named functions and closures capture their environment identically.

Recursive Closures

A closure can call itself recursively if it is assigned to a variable that is in scope at the time of the call:

let factorial = fn(n) {
    if n <= 1 { 1 }
    else { n * factorial(n - 1) }
}
say factorial(5)    // 120

Type Annotations

Closures do not currently support parameter or return type annotations. The types of parameters and the return value are inferred at runtime.

When Guards

A when expression performs multi-way branching on a scrutinee value using comparison operators. Each arm specifies a comparison operation applied to the scrutinee; the first matching arm’s result expression is returned.

Syntax

when expression {
    op value -> result,
    op value -> result,
    else -> result
}

The scrutinee is the expression after when. Each arm consists of a comparison operator, a value to compare against, the -> arrow, and a result expression. The optional else arm matches when no other arm does.

Basic Usage

let label = when age {
    < 13 -> "child",
    < 18 -> "teen",
    < 65 -> "adult",
    else -> "senior"
}
say label

The scrutinee age is evaluated once. Each arm’s operator and value are applied to the scrutinee in order. The first arm whose comparison returns true provides the result.

Comparison Operators in Arms

Arms support any comparison operator:

OperatorMeaning
<Less than
>Greater than
<=Less than or equal
>=Greater than or equal
==Equal
!=Not equal
let status = when code {
    == 200 -> "ok",
    == 404 -> "not found",
    == 500 -> "server error",
    >= 400 -> "client error",
    else -> "unknown"
}

Evaluation Semantics

  1. The scrutinee expression is evaluated exactly once.
  2. Arms are tested top to bottom.
  3. For each arm, the arm’s comparison operator is applied with the scrutinee as the left operand and the arm’s value as the right operand.
  4. The first arm that produces true determines the result: its result expression is evaluated and returned.
  5. If no arm matches and an else arm is present, the else result is returned.
  6. If no arm matches and no else arm is present, the when expression evaluates to null.

When as an Expression

when produces a value and can be used anywhere an expression is expected:

say when score {
    >= 90 -> "A",
    >= 80 -> "B",
    >= 70 -> "C",
    else -> "F"
}

let discount = when items {
    > 100 -> 0.20,
    > 50 -> 0.10,
    > 10 -> 0.05,
    else -> 0.0
}

When as a Statement

when can also appear at the statement level:

when temperature {
    > 100 -> say "boiling",
    < 0 -> say "freezing",
    else -> say "normal"
}

Arm Result Expressions

Each arm’s result is a single expression. For multi-statement logic, use a block expression or call a function:

let result = when level {
    > 10 -> {
        let bonus = level * 2
        bonus + 100
    },
    else -> 0
}

The else Arm

The else arm is a catch-all that matches when no other arm does. It must be the last arm if present.

let kind = when x {
    > 0 -> "positive",
    < 0 -> "negative",
    else -> "zero"
}

If no else arm is provided and no arm matches, the when expression evaluates to null.

Differences from Match

when guards and match expressions serve different purposes:

Featurewhenmatch
Comparison styleOperator-based guardsStructural pattern matching
ScrutineeCompared via operatorsDestructured via patterns
Use caseNumeric/comparable rangesADT variant matching

See Match Expressions for structural pattern matching.

Match Expressions

A match expression performs structural pattern matching on a value. Each arm specifies a pattern; the first arm whose pattern matches the scrutinee has its body evaluated.

Syntax

match expression {
    pattern => body,
    pattern => body,
    ...
}

The scrutinee is the expression after match. Each arm consists of a pattern, the => arrow, and a body (one or more statements). Arms are separated by commas.

Patterns

Forge supports the following pattern forms:

Wildcard Pattern

The underscore _ matches any value and binds nothing.

match x {
    _ => say "matched anything"
}

Literal Pattern

A literal value matches if the scrutinee is equal to that value.

match color {
    "red" => say "stop",
    "green" => say "go",
    "yellow" => say "caution",
    _ => say "unknown"
}

Binding Pattern

A bare identifier binds the matched value to that name within the arm body.

match value {
    x => say "got: {x}"
}

Constructor Pattern

A constructor pattern matches an algebraic data type (ADT) variant and destructures its fields. The pattern names the variant and provides sub-patterns for each field.

type Shape {
    Circle(float)
    Rect(float, float)
    Point
}

let s = Circle(5.0)

match s {
    Circle(r) => say "circle with radius {r}",
    Rect(w, h) => say "rectangle {w} x {h}",
    Point => say "a point",
    _ => say "unknown shape"
}

Nested constructor patterns are supported:

type Expr {
    Num(int)
    Add(Expr, Expr)
}

match expr {
    Add(Num(a), Num(b)) => say "sum: {a + b}",
    Num(n) => say "number: {n}",
    _ => say "complex expression"
}

Evaluation Semantics

  1. The scrutinee expression is evaluated exactly once.
  2. Arms are tested top to bottom.
  3. For each arm, the pattern is matched against the scrutinee:
    • Wildcard: Always matches.
    • Literal: Matches if the scrutinee equals the literal value.
    • Binding: Always matches; binds the scrutinee to the identifier.
    • Constructor: Matches if the scrutinee is an ADT value with the same variant name and the correct number of fields, and all sub-patterns recursively match.
  4. The first matching arm’s body is evaluated. Bindings introduced by the pattern are in scope for the body.
  5. If no arm matches, the match expression evaluates to null.

Match as an Expression

match produces a value and can be used in expression position:

let area = match shape {
    Circle(r) => 3.14159 * r * r,
    Rect(w, h) => w * h,
    _ => 0.0
}

Match as a Statement

match can appear at the statement level:

match event {
    Click(x, y) => handle_click(x, y),
    KeyPress(key) => handle_key(key),
    _ => {}
}

Multi-Statement Arm Bodies

Arm bodies can contain multiple statements. The last expression in the block is the value of the arm.

let result = match data {
    Some(value) => {
        let processed = transform(value)
        validate(processed)
        processed
    },
    None => default_value()
}

ADT Matching

Match is the primary mechanism for working with algebraic data types (see Algebraic Data Types).

type Result {
    Ok(any)
    Err(string)
}

fn handle(r) {
    match r {
        Ok(value) => say "success: {value}",
        Err(msg) => say "error: {msg}"
    }
}

Exhaustiveness

Forge does not currently enforce exhaustive matching. If no arm matches the scrutinee, the match expression evaluates to null. Use a wildcard _ arm as the final arm to ensure all cases are handled.

Differences from When Guards

Featurematchwhen
Arrow syntax=>->
Matching styleStructural patternsComparison operators
DestructuringYes (ADT variants)No
Use caseADT variants, literal dispatchNumeric ranges, comparisons

See When Guards for operator-based branching.

Statements

A statement is a syntactic construct that performs an action. Unlike expressions, statements do not produce values (with the exception of expression statements, where an expression is evaluated for its side effects and the result is discarded).

A Forge program is a sequence of statements executed top to bottom.

Statement Categories

Declarations

Declarations introduce new names into the current scope.

  • Variable declaration: let x = expr / set x to expr – binds a value to a name. See Variable Declaration.
  • Function declaration: fn name(params) { body } / define name(params) { body } – binds a function to a name. See Function Declaration.
  • Destructuring declaration: let {a, b} = obj / unpack {a, b} from obj – binds multiple names from a compound value. See Variable Declaration.

Assignments

Assignments change the value of an existing binding.

  • Simple assignment: x = expr / change x to expr
  • Compound assignment: x += expr, x -= expr, x *= expr, x /= expr
  • Field assignment: obj.field = expr
  • Index assignment: arr[i] = expr

See Assignment.

Control Flow

Control flow statements direct the order of execution.

  • Conditional: if condition { body } with optional else/otherwise/nah clauses. See Control Flow.
  • When guards: when expr { arms } – multi-way branch on a value. See When Guards.
  • Match: match expr { arms } – structural pattern matching. See Match Expressions.

Loops

Loop statements execute a body repeatedly.

  • For-in: for item in collection { body } / for each item in collection { body }
  • While: while condition { body }
  • Loop: loop { body } – infinite loop, exit with break
  • Repeat: repeat N times { body } – counted loop

See Loops.

Jump Statements

Jump statements transfer control to a different point in the program.

  • return: Exits the current function, optionally with a value.
  • break: Exits the innermost loop.
  • continue: Skips to the next iteration of the innermost loop.

See Return, Break, Continue.

Module Statements

Module statements manage code organization across files.

  • import: import "file.fg" – executes another file and imports its definitions.

See Import and Export.

Expression Statements

Any expression can appear as a statement. The expression is evaluated and its result is discarded. This is how function calls with side effects are written.

say "hello"             // function call as statement
push(items, 42)         // side-effecting call

Statement Terminators

Forge does not require semicolons or other explicit statement terminators. Statements are separated by newlines. Multiple statements may appear on a single line if they are unambiguous to the parser.

let x = 10
let y = 20
say x + y

Blocks

A block is a sequence of statements enclosed in { and }. Blocks appear as the body of functions, loops, conditionals, and other compound statements. Blocks create a new scope: variables declared inside a block are not visible outside it.

let x = "outer"
{
    let x = "inner"
    say x           // "inner"
}
say x               // "outer"

Variable Declaration

Variable declarations introduce new bindings in the current scope. Forge supports both classic and natural syntax forms, with optional mutability and destructuring.

Immutable Variables

By default, variables are immutable. An immutable binding cannot be reassigned after initialization.

Classic Syntax

let name = "Alice"
let age = 30
let items = [1, 2, 3]

Natural Syntax

set name to "Alice"
set age to 30
set items to [1, 2, 3]

Both forms are semantically identical. The variable is bound to the result of evaluating the right-hand expression.

Mutable Variables

To allow a variable to be reassigned after its initial declaration, use the mut keyword.

Classic Syntax

let mut count = 0
count = count + 1       // allowed

Natural Syntax

set mut count to 0
change count to count + 1   // allowed

Attempting to reassign an immutable variable produces a runtime error:

let x = 10
x = 20          // runtime error: cannot reassign immutable variable 'x'

Type Annotations

Variable declarations may include an optional type annotation after the variable name:

let name: string = "Alice"
let age: int = 30
let ratio: float = 0.5

Type annotations are checked by the type checker when enabled. They do not affect runtime behavior in the interpreter.

Initializer Expressions

The right-hand side of a variable declaration is any valid expression:

let sum = 1 + 2 + 3
let greeting = "Hello, {name}!"
let data = fs.read("config.json")
let result = compute(x, y)

Every variable declaration requires an initializer. There is no uninitialized variable syntax.

Destructuring

Forge supports destructuring assignment for objects and arrays.

Object Destructuring

Classic Syntax

let person = { name: "Alice", age: 30, city: "NYC" }
let { name, age } = person
say name    // "Alice"
say age     // 30

Natural Syntax

let person = { name: "Alice", age: 30, city: "NYC" }
unpack { name, age } from person
say name    // "Alice"
say age     // 30

Object destructuring extracts the named fields from an object and binds them to variables with the same names.

Array Destructuring

let coords = [10, 20, 30]
let [x, y, z] = coords
say x   // 10
say y   // 20
say z   // 30

Rest Pattern

Array destructuring supports a rest pattern to capture remaining elements:

let items = [1, 2, 3, 4, 5]
let [first, ...rest] = items
say first   // 1
say rest    // [2, 3, 4, 5]

Scope

Variables are scoped to the block in which they are declared. A variable declared inside an if body, loop body, or function body is not accessible outside that block.

if true {
    let x = 42
    say x       // 42
}
// x is not accessible here

Inner scopes can shadow variables from outer scopes:

let x = "outer"
{
    let x = "inner"
    say x           // "inner"
}
say x               // "outer"

Multiple Declarations

Each let/set statement declares a single binding (or a destructuring pattern). To declare multiple variables, use separate statements:

let x = 1
let y = 2
let z = 3

Assignment

Assignment statements change the value of an existing mutable binding. The target must have been declared with mut (or let mut / set mut).

Simple Assignment

Classic Syntax

let mut x = 10
x = 20
say x       // 20

Natural Syntax

set mut x to 10
change x to 20
say x       // 20

Both forms evaluate the right-hand expression and store the result in the named variable.

Mutability Requirement

Only variables declared with mut can be reassigned. Attempting to assign to an immutable variable produces a runtime error:

let x = 10
x = 20          // runtime error: cannot reassign immutable variable 'x'

Compound Assignment

Compound assignment operators combine an arithmetic operation with assignment. The target must be mutable.

OperatorEquivalent To
x += yx = x + y
x -= yx = x - y
x *= yx = x * y
x /= yx = x / y
let mut count = 0
count += 1          // count is 1
count += 5          // count is 6
count *= 2          // count is 12
count -= 3          // count is 9
count /= 3          // count is 3

Compound assignment with += on strings performs concatenation:

let mut msg = "hello"
msg += " world"
say msg     // "hello world"

Field Assignment

Fields on objects and struct instances can be assigned using dot notation:

let mut user = { name: "Alice", age: 30 }
user.age = 31
say user.age    // 31

Nested field assignment is supported:

let mut config = { server: { port: 8080 } }
config.server.port = 3000
say config.server.port  // 3000

Index Assignment

Array elements and object keys can be assigned using bracket notation:

let mut items = [10, 20, 30]
items[1] = 99
say items       // [10, 99, 30]

let mut obj = { a: 1, b: 2 }
obj["a"] = 100
say obj.a       // 100

Assignment Is Not an Expression

In Forge, assignment is a statement, not an expression. Assignment does not produce a value and cannot be used in expression position:

// This is NOT valid:
// let y = (x = 5)

// Use separate statements:
let mut x = 0
x = 5
let y = x

Evaluation Order

In an assignment target = expression, the right-hand expression is evaluated first, then the result is stored in the target location.

For compound assignment target += expression, the current value of the target is read, the operation is performed with the right-hand expression, and the result is stored back.

Control Flow

Control flow statements direct the order of execution based on conditions.

If Statements

The if statement executes a block of code when a condition is truthy.

if temperature > 100 {
    say "boiling"
}

The condition is any expression. It is evaluated using Forge’s truthiness rules: false and null are falsy, everything else is truthy.

If-Else

An else clause provides an alternative block when the condition is falsy. Forge supports three equivalent keywords for the else clause:

Classic: else

if age >= 18 {
    say "adult"
} else {
    say "minor"
}

Natural: otherwise

if age >= 18 {
    say "adult"
} otherwise {
    say "minor"
}

Casual: nah

if age >= 18 {
    say "adult"
} nah {
    say "minor"
}

All three forms are semantically identical. The else block executes when the condition is falsy.

If-Else If Chains

Multiple conditions can be tested in sequence using else if (or otherwise if / nah if):

if score >= 90 {
    say "A"
} else if score >= 80 {
    say "B"
} else if score >= 70 {
    say "C"
} else {
    say "F"
}

Conditions are tested top to bottom. The first truthy condition’s block is executed. If no condition is truthy and an else clause is present, its block executes.

Nested If Statements

If statements can be nested arbitrarily:

if user != null {
    if user.role == "admin" {
        say "admin access"
    } else {
        say "regular access"
    }
} else {
    say "not logged in"
}

Block Scoping

Variables declared inside an if or else block are scoped to that block:

if true {
    let msg = "inside"
    say msg     // "inside"
}
// msg is not accessible here

No Ternary Operator

Forge does not have a ternary conditional operator (condition ? a : b). Use an if-else statement or a when expression instead:

// Using when as a conditional expression
let label = when age {
    >= 18 -> "adult",
    else -> "minor"
}

If as a Statement

if is always a statement in Forge. It does not produce a value that can be used in expression position. To select between values conditionally, use a when expression.

Truthiness in Conditions

The condition in an if statement follows Forge’s truthiness rules:

if 0 {
    say "zero is truthy"        // this executes
}

if "" {
    say "empty string is truthy"    // this executes
}

if null {
    say "unreachable"
} else {
    say "null is falsy"         // this executes
}

if false {
    say "unreachable"
} else {
    say "false is falsy"        // this executes
}

See Truthiness Rules for the complete specification.

Loops

Loop statements execute a body repeatedly. Forge provides several loop forms for different iteration patterns.

For-In Loops

The for-in loop iterates over elements of a collection.

let names = ["Alice", "Bob", "Charlie"]
for name in names {
    say "Hello, {name}"
}

For Each Variant

The for each form is equivalent to for-in:

for each name in names {
    say "Hello, {name}"
}

Iterating Over Arrays

let nums = [10, 20, 30]
for n in nums {
    say n
}
// Output: 10, 20, 30

Iterating Over Objects

When iterating over an object, the loop variable receives each key:

let user = { name: "Alice", age: 30 }
for key in user {
    say "{key}: {user[key]}"
}

Iterating with Index

Use enumerate or a two-variable for loop to access both index and value:

let items = ["a", "b", "c"]
for i, item in items {
    say "{i}: {item}"
}
// Output: 0: a, 1: b, 2: c

Iterating Over Ranges

The range function generates a sequence of integers:

for i in range(0, 5) {
    say i
}
// Output: 0, 1, 2, 3, 4

range(start, end) produces integers from start (inclusive) to end (exclusive).

While Loops

The while loop executes as long as its condition is truthy.

let mut count = 0
while count < 5 {
    say count
    count += 1
}
// Output: 0, 1, 2, 3, 4

The condition is evaluated before each iteration. If the condition is falsy on the first check, the body never executes.

Loop (Infinite)

The loop keyword creates an infinite loop. Use break to exit.

let mut n = 0
loop {
    if n >= 3 {
        break
    }
    say n
    n += 1
}
// Output: 0, 1, 2

Repeat N Times

The repeat loop executes a body a fixed number of times.

repeat 3 times {
    say "hello"
}
// Output: hello, hello, hello

The count expression is evaluated once before the loop begins. The body executes exactly that many times.

Break

The break statement exits the innermost enclosing loop immediately.

for i in range(0, 100) {
    if i == 5 {
        break
    }
    say i
}
// Output: 0, 1, 2, 3, 4

break can be used with for, while, loop, and repeat.

Continue

The continue statement skips the rest of the current iteration and proceeds to the next one.

for i in range(0, 5) {
    if i == 2 {
        continue
    }
    say i
}
// Output: 0, 1, 3, 4

Nested Loops

Loops can be nested. break and continue apply to the innermost loop only.

for i in range(0, 3) {
    for j in range(0, 3) {
        if j == 1 {
            break       // exits inner loop only
        }
        say "{i},{j}"
    }
}
// Output: 0,0  1,0  2,0

Loop Scope

Variables declared inside a loop body are scoped to each iteration:

for i in range(0, 3) {
    let msg = "iteration {i}"
    say msg
}
// msg is not accessible here

The loop variable itself (i in for i in ...) is scoped to the loop body.

Wait in Loops

The wait statement can be used inside loops to introduce delays:

repeat 3 times {
    say "tick"
    wait 1 second
}

Function Declaration

Function declarations introduce named, callable units of code. Forge supports both classic and natural syntax forms.

Basic Declaration

Classic Syntax

fn greet(name) {
    say "Hello, {name}!"
}

Natural Syntax

define greet(name) {
    say "Hello, {name}!"
}

Both forms are semantically identical. The function is bound to the given name in the current scope.

Parameters

Parameters are comma-separated identifiers enclosed in parentheses.

fn add(a, b) {
    a + b
}
say add(3, 4)   // 7

No Parameters

Functions with no parameters use empty parentheses:

fn hello() {
    say "hello"
}
hello()

Default Parameters

Parameters can have default values. Default values are used when the caller does not provide an argument for that position.

fn greet(name, greeting = "Hello") {
    say "{greeting}, {name}!"
}
greet("Alice")              // "Hello, Alice!"
greet("Bob", "Hi")          // "Hi, Bob!"

Default parameters must appear after all required parameters.

Variadic Parameters

Forge does not support variadic parameters (rest parameters). To accept a variable number of arguments, use an array parameter:

fn sum_all(numbers) {
    reduce(numbers, 0, fn(acc, n) { acc + n })
}
say sum_all([1, 2, 3, 4])  // 10

Return Type Annotation

An optional return type annotation may follow the parameter list:

fn square(n: int): int {
    n * n
}

Type annotations are checked by the type checker when enabled. They do not affect runtime behavior in the interpreter.

Return Values

Implicit Return

The last expression in a function body is its return value. This is the idiomatic way to return values in Forge.

fn double(x) {
    x * 2
}
say double(5)   // 10

Explicit Return

The return keyword exits the function immediately with a value:

fn abs(x) {
    if x < 0 {
        return -x
    }
    x
}

A bare return without a value returns null:

fn log_if_positive(x) {
    if x <= 0 {
        return
    }
    say "positive: {x}"
}

See Return, Break, Continue for details.

Function Scope

Functions create a new scope. Variables declared inside a function are not accessible outside it. Functions can access variables from their enclosing scope (closure behavior).

let multiplier = 10

fn scale(x) {
    x * multiplier      // accesses 'multiplier' from outer scope
}
say scale(5)    // 50

Recursion

Functions can call themselves recursively:

fn factorial(n) {
    if n <= 1 { return 1 }
    n * factorial(n - 1)
}
say factorial(5)    // 120
fn fib(n) {
    if n <= 1 { return n }
    fib(n - 1) + fib(n - 2)
}
say fib(10)     // 55

Async Functions

Async functions are declared with async fn (classic) or forge (natural):

async fn fetch_data(url) {
    let resp = await http.get(url)
    resp.body
}

// Natural syntax
forge fetch_data(url) {
    let resp = hold http.get(url)
    resp.body
}

Async functions return a future that must be awaited with await / hold. See Async Functions.

Nested Functions

Functions can be declared inside other functions:

fn outer() {
    fn inner() {
        say "inside"
    }
    inner()
}
outer()     // "inside"

Inner functions have access to the outer function’s scope.

Functions Are Values

Function declarations create values that can be stored in variables, passed as arguments, and returned from other functions:

fn add(a, b) { a + b }
fn sub(a, b) { a - b }

let ops = [add, sub]
say ops[0](10, 3)   // 13
say ops[1](10, 3)   // 7

Parameter Type Annotations

Parameters can include optional type annotations:

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

These annotations are informational for the type checker and do not enforce types at runtime in the interpreter.

Return, Break, Continue

Jump statements transfer control to a different point in the program, interrupting the normal top-to-bottom execution flow.

Return

The return statement exits the current function and optionally provides a return value.

Return with a Value

fn square(x) {
    return x * x
}
say square(5)   // 25

Return without a Value

A bare return returns null from the function:

fn maybe_log(x) {
    if x <= 0 {
        return          // returns null
    }
    say "value: {x}"
}

Implicit Return

The last expression in a function body is automatically used as the return value. Explicit return is only needed for early exit.

fn double(x) {
    x * 2           // implicit return
}
say double(5)   // 10

When using if-else as the last statement, the last expression in the executed branch becomes the return value:

fn abs(x) {
    if x < 0 {
        -x
    } else {
        x
    }
}
say abs(-3)     // 3

Return in Nested Blocks

return exits the enclosing function, not just the current block:

fn find_first_negative(items) {
    for item in items {
        if item < 0 {
            return item     // exits the function, not just the loop
        }
    }
    null
}
say find_first_negative([1, 2, -3, 4])  // -3

Return from Closures

A return inside a closure exits the closure, not the outer function:

fn process(items) {
    let results = map(items, fn(x) {
        if x < 0 {
            return 0        // exits the closure, not process()
        }
        x * 2
    })
    results
}
say process([1, -2, 3])    // [2, 0, 6]

Break

The break statement exits the innermost enclosing loop immediately. Execution continues with the first statement after the loop.

Break in For Loops

for i in range(0, 100) {
    if i >= 5 {
        break
    }
    say i
}
// Output: 0, 1, 2, 3, 4
say "done"

Break in While Loops

let mut n = 0
while true {
    if n >= 3 {
        break
    }
    say n
    n += 1
}
// Output: 0, 1, 2

Break in Loop

The break statement is the only way to exit a loop (infinite loop):

let mut count = 0
loop {
    count += 1
    if count > 5 {
        break
    }
}
say count   // 6

Break in Nested Loops

break only exits the innermost loop:

for i in range(0, 3) {
    for j in range(0, 10) {
        if j >= 2 {
            break       // exits inner loop only
        }
        say "{i},{j}"
    }
    // continues with next i
}

Break Outside a Loop

Using break outside of a loop produces a runtime error.

Continue

The continue statement skips the rest of the current loop iteration and proceeds to the next iteration.

Continue in For Loops

for i in range(0, 5) {
    if i == 2 {
        continue        // skip i == 2
    }
    say i
}
// Output: 0, 1, 3, 4

Continue in While Loops

let mut i = 0
while i < 5 {
    i += 1
    if i % 2 == 0 {
        continue        // skip even numbers
    }
    say i
}
// Output: 1, 3, 5

Continue in Nested Loops

Like break, continue applies to the innermost loop:

for i in range(0, 3) {
    for j in range(0, 3) {
        if j == 1 {
            continue    // skips j == 1 in inner loop
        }
        say "{i},{j}"
    }
}
// Output: 0,0  0,2  1,0  1,2  2,0  2,2

Continue Outside a Loop

Using continue outside of a loop produces a runtime error.

Summary

StatementContextEffect
return exprFunctionExit function, return expr
returnFunctionExit function, return null
breakLoopExit innermost loop
continueLoopSkip to next iteration of innermost loop

Import and Export

The import statement loads definitions from external Forge source files or references built-in modules.

Importing Files

To import all top-level definitions from another Forge source file:

import "utils.fg"

The import path is a string literal specifying the file to load. The interpreter resolves the path relative to the current working directory and checks the following locations in order:

  1. {path} – the exact path as given
  2. {path}.fg – with the .fg extension appended
  3. forge_modules/{path}/main.fg – in the forge_modules directory

If no file is found at any of these locations, a runtime error is produced.

Import Semantics

When a file is imported, the following steps occur:

  1. The source file is read and parsed.
  2. A new interpreter instance is created.
  3. The imported file is executed in the new interpreter.
  4. Top-level definitions (fn and let bindings) are copied into the importing file’s scope.

Importantly, each import creates a fresh interpreter. Side effects in the imported file (such as printing) occur during import. The imported file does not share mutable state with the importing file.

// utils.fg
fn double(x) { x * 2 }
fn triple(x) { x * 3 }
let PI = 3.14159
// main.fg
import "utils.fg"
say double(5)       // 10
say triple(5)       // 15
say PI              // 3.14159

Selective Imports

To import specific names from a file, list them after the path:

import { double, PI } from "utils.fg"
say double(5)       // 10
say PI              // 3.14159
// triple is NOT imported

Only the listed names are copied into the current scope.

Built-in Modules

Forge’s standard library modules (math, fs, io, crypto, db, pg, env, json, regex, log, term, http, csv, exec, time) are automatically available without import. Attempting to import a built-in module name produces an error with guidance:

import "math"
// Error: 'math' is a built-in module — it's already available. Use it directly: math.function()

Built-in modules are accessed via dot notation:

say math.sqrt(16)       // 4.0
say math.pi             // 3.141592653589793
let data = fs.read("file.txt")

Module-Level Execution

When a file is imported, all of its top-level statements execute. This includes not just declarations but also expression statements and side effects:

// setup.fg
say "setting up..."         // prints during import
let config = { debug: true }

fn get_config() { config }
// main.fg
import "setup.fg"           // prints "setting up..."
let c = get_config()
say c.debug                 // true

Circular Imports

Forge does not detect or prevent circular imports. A circular import chain will cause infinite recursion and a stack overflow. It is the programmer’s responsibility to avoid circular dependencies.

No Namespacing

Imported definitions are placed directly into the importing file’s scope. There is no namespace or module prefix for file imports. If two imported files define the same name, the later import overwrites the earlier one.

import "a.fg"       // defines fn helper()
import "b.fg"       // also defines fn helper() -- overwrites a.fg's version

No Export Keyword

Forge does not have an explicit export keyword. All top-level fn and let bindings in a file are automatically available for import by other files.

Re-Imports

Importing the same file multiple times re-executes it each time. There is no import caching or module singleton behavior for file imports.

Type System

This chapter defines the type system of Forge. Forge is dynamically typed at runtime but provides structural mechanisms for organizing data and behavior: struct definitions, method attachment, interface contracts, composition via embedding, and structural satisfaction checking.

Overview

Forge’s type system is built on five pillars:

  1. Struct definitions (thing/struct) — Define named record types with typed fields and optional defaults.
  2. Method blocks (give/impl) — Attach instance and static methods to struct types after definition.
  3. Interface contracts (power/interface) — Declare behavioral contracts that types may fulfill.
  4. Composition (has) — Embed one struct inside another with automatic field and method delegation.
  5. Structural satisfaction (satisfies) — Test whether a value’s type fulfills an interface at runtime, regardless of explicit declaration.

Dynamic Foundation

All Forge values are represented at runtime by the Value enum. There is no compile-time type erasure or monomorphization. Type annotations on struct fields and function parameters are documentation and future-proofing; the interpreter does not enforce them at assignment time in the current version.

The typeof builtin returns a string naming the runtime type:

typeof(42)        // "Int"
typeof("hello")   // "String"
typeof(true)      // "Bool"
typeof(null)      // "Null"
typeof([1, 2])    // "Array"
typeof({a: 1})    // "Object"

Struct instances are Object values with a __type__ field that records the struct name.

Type Identity

Every struct instance carries a __type__ field set to the struct’s name as a string. This field is automatically inserted during construction and is used by the runtime for method dispatch, interface satisfaction checking, and embedded-field delegation.

thing Point { x: Int, y: Int }
let p = Point { x: 1, y: 2 }
p.__type__    // "Point"
typeof(p)     // "Object"

The typeof builtin returns "Object" for all struct instances. The __type__ field distinguishes between different struct types at a finer granularity.

No Generics

Forge does not currently support generic types or parameterized type constructors. All collections (arrays, objects) are heterogeneous. Interface methods are checked by name and arity, not by parameter types.

Subsections

The following subsections define each type system feature in detail:

Struct Definitions

Structs are named record types that group related fields into a single value. Forge provides dual syntax for defining structs: thing (natural) and struct (classic).

Syntax

StructDef       = ("thing" | "struct") Identifier "{" FieldList "}"
FieldList       = Field ("," Field)* ","?
Field           = EmbedField | PlainField
PlainField      = Identifier (":" TypeAnnotation)? ("=" Expression)?
EmbedField      = "has" Identifier ":" TypeAnnotation
TypeAnnotation  = Identifier

Defining a Struct

The thing keyword (or its classic alias struct) introduces a new struct type. The struct name must be a valid identifier and by convention uses PascalCase.

thing Person {
    name: String,
    age: Int
}

Classic syntax:

struct Person {
    name: String,
    age: Int
}

Both forms are semantically identical. The parser produces the same StructDef AST node regardless of which keyword is used.

Fields

Each field has a name and an optional type annotation. Type annotations follow the field name after a colon. In the current implementation, type annotations are parsed and stored but not enforced at runtime.

thing Config {
    host: String,
    port: Int,
    debug: Bool
}

Fields without type annotations are permitted:

thing Pair {
    first,
    second
}

Default Values

Fields may specify a default value using = after the type annotation (or after the field name if no annotation is present). See Default Values for full details.

thing Config {
    host: String = "localhost",
    port: Int = 8080,
    debug: Bool = false
}

Construction

Struct instances are created using the struct name followed by a field initializer block. The result is an Object value with the specified fields plus an automatically inserted __type__ field.

thing Point { x: Int, y: Int }

let p = Point { x: 10, y: 20 }
// p is { __type__: "Point", x: 10, y: 20 }

Fields may be provided in any order. Fields with default values may be omitted; default values are applied first, then explicitly provided fields override them.

thing Config {
    host: String = "localhost",
    port: Int = 8080
}

let c = Config { port: 3000 }
// c is { __type__: "Config", host: "localhost", port: 3000 }

The __type__ Field

Every struct instance automatically receives a __type__ field set to the struct’s name as a String value. This field is inserted after all user-specified fields during construction. It is used by the runtime for:

  • Method dispatch in give/impl blocks
  • Interface satisfaction checking in satisfies
  • Embedded field delegation via has

The __type__ field is a regular field and can be read like any other:

thing Dog { name: String }
let d = Dog { name: "Rex" }
say d.__type__    // "Dog"

Manually setting __type__ in the constructor is permitted but will be overwritten by the automatic insertion.

Registration

When a StructDef statement is executed, the interpreter:

  1. Registers the struct name in the environment as a BuiltIn("struct:Name") sentinel value. This value is used to identify static method calls (Name.method()).
  2. Records any embedded fields in the embedded_fields table for delegation.
  3. Records any default values in the struct_defaults table, evaluating default expressions at definition time.

The struct name can subsequently be used as a constructor in StructInit expressions.

Field Access

Fields are accessed using dot notation:

thing Person { name: String, age: Int }
let p = Person { name: "Alice", age: 30 }

say p.name    // "Alice"
say p.age     // 30

If the field does not exist on the object directly, the runtime checks embedded sub-objects before reporting an error. See Composition.

Method Blocks

Method blocks attach functions to a struct type after its definition. Forge provides dual syntax: give (natural) and impl (classic). Methods are stored in the interpreter’s method tables and dispatched based on the instance’s __type__ field.

Syntax

MethodBlock      = GiveBlock | ImplBlock
GiveBlock        = "give" TypeName AbilityClause? "{" MethodDef* "}"
ImplBlock        = "impl" (TypeName | AbilityForType) "{" MethodDef* "}"
AbilityClause    = "the" "power" InterfaceName
AbilityForType   = InterfaceName "for" TypeName
MethodDef        = ("fn" | "define") Identifier "(" ParamList ")" Block
TypeName         = Identifier
InterfaceName    = Identifier

Instance Methods

An instance method is a method whose first parameter is named it. The it parameter receives the struct instance on which the method is called — it is Forge’s equivalent of self or this in other languages.

thing Person {
    name: String,
    age: Int
}

give Person {
    define greet(it) {
        say "Hello, I'm " + it.name
    }

    define birthday(it) {
        return it.age + 1
    }
}

Classic syntax:

impl Person {
    fn greet(it) {
        say "Hello, I'm " + it.name
    }
}

Both give and impl are semantically identical. The parser produces the same ImplBlock AST node.

Method Invocation

Instance methods are called using dot notation on a struct instance. The runtime automatically passes the instance as the it argument:

let p = Person { name: "Alice", age: 30 }
p.greet()       // prints "Hello, I'm Alice"
p.birthday()    // returns 31

When the interpreter encounters p.greet(), it:

  1. Evaluates p to get the receiver object.
  2. Reads p.__type__ to get the type name ("Person").
  3. Looks up "greet" in method_tables["Person"].
  4. Prepends p to the argument list as the it parameter.
  5. Calls the resolved function with the full argument list.

Static Methods

A method without it as its first parameter is a static method. Static methods are called on the type name itself, not on instances. See Static Methods for full details.

give Person {
    define species() {
        return "Homo sapiens"
    }
}

Person.species()    // "Homo sapiens"

Additive Blocks

Multiple give/impl blocks for the same type are additive. Each block adds its methods to the existing method table without removing previously defined methods.

thing Car {
    make: String
}

give Car {
    define brand(it) {
        return it.make
    }
}

give Car {
    define honk(it) {
        say "Beep!"
    }
}

let c = Car { make: "Toyota" }
c.brand()    // "Toyota"
c.honk()     // prints "Beep!"

If a later block defines a method with the same name as an existing method, the later definition overwrites the earlier one in the method table.

Method Table Storage

The interpreter maintains two HashMap tables:

TableKeyValueLookup
method_tablesType nameIndexMap<String, Value>Instance method dispatch
static_methodsType nameIndexMap<String, Value>Static method dispatch

When a give/impl block is executed, each method is inserted into method_tables under the type name. Methods without an it parameter are additionally inserted into static_methods.

Method Resolution Order

When resolving obj.method(args) on a typed object:

  1. Direct field — If the object has a field named method that is callable, it is invoked.
  2. Method table — The runtime looks up method_tables[obj.__type__][method].
  3. Embedded delegation — If not found, the runtime checks each embedded field’s type for the method in method_tables. See Composition.
  4. Known builtins — Certain method names (e.g., map, filter, push) are recognized as builtin functions and dispatched accordingly.
  5. Error — If no match is found, a runtime error is raised: no method 'method' on TypeName.

Mixed Syntax

Natural (define) and classic (fn) function syntax may be used interchangeably within give and impl blocks:

give Greeter {
    define hello(it) {
        say "hi from " + it.name
    }

    fn goodbye(it) {
        say "bye from " + it.name
    }
}

Interface Contracts

Interfaces define behavioral contracts — a set of method signatures that a type must implement. Forge provides dual syntax: power (natural) and interface (classic). Implementing an interface is verified at definition time when the give...the power or impl...for block is executed.

Syntax

InterfaceDef     = ("power" | "interface") Identifier "{" MethodSig* "}"
MethodSig        = ("fn" | "define") Identifier "(" ParamList ")" ("->" TypeAnnotation)?
ImplInterface    = "give" TypeName "the" "power" InterfaceName "{" MethodDef* "}"
                 | "impl" InterfaceName "for" TypeName "{" MethodDef* "}"

Defining an Interface

The power keyword (or its classic alias interface) introduces a named interface. An interface body contains method signatures — method names with parameter lists and optional return type annotations.

power Greetable {
    fn greet(it) -> String
}

Classic syntax:

interface Greetable {
    fn greet(it) -> String
}

Both forms produce the same InterfaceDef AST node.

Interface Registration

When an InterfaceDef statement is executed, the interpreter:

  1. Builds an array of method specification objects. Each object contains:
    • name — the method name as a String.
    • param_count — the number of parameters as an Int.
    • return_type — the return type annotation as a String, if present.
  2. Creates an interface metadata object with fields __kind__: "interface", name, and methods.
  3. Registers the interface in the environment under both its name and __interface_Name__.

The interface object is a regular Object value and can be passed to functions like satisfies.

Implementing an Interface

To declare that a type fulfills an interface, use give...the power (natural) or impl...for (classic):

thing Cat {
    name: String
}

power Greetable {
    fn greet(it) -> String
}

give Cat the power Greetable {
    define greet(it) {
        return "Meow, I'm " + it.name
    }
}

Classic syntax:

interface Named {
    fn get_name(it) -> String
}

impl Named for Animal {
    fn get_name(it) {
        return it.name
    }
}

Validation at Definition Time

When a give...the power or impl...for block is executed, the runtime validates that every method required by the interface is present in the type’s method table (including methods added by the current block and any previous give/impl blocks).

The validation checks method presence by name. It does not verify parameter counts, parameter types, or return types.

If a required method is missing, a runtime error is raised:

'Cat' does not implement 'greet' required by power 'Greetable'

This error occurs at the point where the give...the power block is executed, not at a later call site.

Interface Without Explicit Implementation

A type may satisfy an interface without ever using give...the power or impl...for. Forge supports Go-style structural typing through the satisfies function. See Structural Satisfaction.

The give...the power syntax provides two benefits over implicit satisfaction:

  1. Early validation — Errors are reported at the implementation site rather than at a distant call site.
  2. Documentation — The code explicitly declares the relationship between a type and an interface.

Multiple Interfaces

A type may implement multiple interfaces through separate give...the power blocks:

power Speakable {
    fn speak(it) -> String
}

power Trainable {
    fn train(it, command: String)
    fn obey(it, command: String) -> Bool
}

thing Dog {
    name: String
}

give Dog the power Speakable {
    define speak(it) {
        return "Woof!"
    }
}

give Dog the power Trainable {
    define train(it, command) {
        say it.name + " is learning " + command
    }

    define obey(it, command) {
        return true
    }
}

Each give...the power block independently validates that its interface’s requirements are met. Methods from earlier blocks (including plain give blocks without an interface) count toward satisfaction.

Return Type Annotations

Return type annotations in interface method signatures are stored in the interface metadata but are not enforced at runtime. They serve as documentation:

power Hashable {
    fn hash(it) -> String
}

The -> String annotation is recorded in the method specification but the runtime does not check that hash actually returns a String.

Composition

Forge supports struct composition through the has keyword, which embeds one struct inside another. Embedded fields enable automatic delegation of both field access and method calls to the inner struct, providing a composition-based alternative to inheritance.

Syntax

EmbedField = "has" Identifier ":" TypeAnnotation

The has keyword appears in a struct field position and marks the field as embedded.

Defining Embedded Fields

Use has inside a struct definition to embed another type:

thing Address {
    street: String,
    city: String,
    zip: String
}

thing Employee {
    name: String,
    has addr: Address
}

The addr field is an embedded field of type Address. The has keyword tells the runtime to register this field for delegation.

Construction

Embedded fields are initialized like regular fields during construction:

let emp = Employee {
    name: "Alice",
    addr: Address {
        street: "123 Main St",
        city: "Springfield",
        zip: "62701"
    }
}

Field Delegation

When a field is accessed on a struct instance and the field is not found directly on the object, the runtime checks each embedded sub-object for the field. This enables transparent access to inner fields:

// Direct access (always works)
emp.addr.city      // "Springfield"

// Delegated access (through embedding)
emp.city           // "Springfield"
emp.street         // "123 Main St"

The delegation algorithm for obj.field:

  1. Check if obj has a direct field named field. If found, return it.
  2. Read obj.__type__ to get the type name.
  3. Look up the type name in embedded_fields to get the list of (field_name, type_name) pairs.
  4. For each embedded field, check if obj[field_name] is an object with the requested field. If found, return it.
  5. If no embedded field contains the requested field, raise a runtime error.

Method Delegation

Method calls are also delegated to embedded types. If a method is not found in the outer type’s method table, the runtime searches each embedded type’s method table:

give Address {
    define full(it) {
        return it.street + ", " + it.city + " " + it.zip
    }
}

// Called on the embedded Address through Employee
emp.full()         // "123 Main St, Springfield 62701"

// Explicit path also works
emp.addr.full()    // "123 Main St, Springfield 62701"

The method delegation algorithm for obj.method(args):

  1. Look up method in method_tables[obj.__type__]. If found, call it with obj prepended as it.
  2. Look up embedded_fields[obj.__type__] to get the list of embedded fields.
  3. For each (embed_field, embed_type) pair, look up method in method_tables[embed_type].
  4. If found, extract obj[embed_field] as the receiver and call the method with the sub-object as it.
  5. If no embedded type has the method, continue to builtin resolution or raise an error.

Multiple Embeddings

A struct may embed multiple fields:

thing Engine {
    horsepower: Int
}

thing Chassis {
    material: String
}

thing Car {
    make: String,
    has engine: Engine,
    has chassis: Chassis
}

give Engine {
    define rev(it) {
        say "Vroom! " + str(it.horsepower) + "hp"
    }
}

give Chassis {
    define describe(it) {
        return it.material + " chassis"
    }
}

let c = Car {
    make: "Toyota",
    engine: Engine { horsepower: 200 },
    chassis: Chassis { material: "Steel" }
}

c.rev()          // prints "Vroom! 200hp"
c.describe()     // "Steel chassis"
c.horsepower     // 200
c.material       // "Steel"

Embedded fields are searched in declaration order. If two embedded types provide the same field name, the first match wins.

Embedding and Interfaces

Delegated methods count toward interface satisfaction. If an embedded type’s method table contains a method required by an interface, the outer type satisfies that interface through delegation:

power Describable {
    fn describe(it) -> String
}

// Car satisfies Describable through its embedded Chassis
satisfies(c, Describable)    // true (via chassis.describe)

Storage

The interpreter maintains an embedded_fields table:

embedded_fields: HashMap<String, Vec<(String, String)>>

The key is the outer struct name. The value is a vector of (field_name, type_name) pairs, one for each has field in the struct definition. This table is populated when the StructDef statement is executed.

Structural Satisfaction

The satisfies function tests whether a value’s type fulfills an interface’s requirements at runtime, without requiring an explicit give...the power or impl...for declaration. This is Forge’s implementation of Go-style structural typing: if a type has the right methods, it satisfies the interface.

Syntax

satisfies(value, InterfaceObject) -> Bool

The satisfies function is a builtin that takes two arguments:

  1. value — Any value, typically a struct instance (an object with a __type__ field).
  2. InterfaceObject — An interface value (an object with __kind__: "interface" and a methods array).

It returns true if the value’s type has all methods required by the interface, false otherwise.

Basic Usage

thing Robot {
    name: String
}

power Speakable {
    fn speak(it) -> String
}

give Robot {
    define speak(it) {
        return "Beep boop, I am " + it.name
    }
}

let r = Robot { name: "R2D2" }
satisfies(r, Speakable)    // true

Note that Robot never explicitly declared give Robot the power Speakable. The satisfies check passes because Robot has a speak method in its method table.

Resolution Algorithm

The satisfies function checks interface satisfaction in two phases:

Phase 1: Structural Check

First, satisfies performs a structural check on the value itself. It examines whether the value (or the object’s fields) contains callable values matching each required method name. This handles objects that carry their methods as fields.

Phase 2: Method Table Check

If the structural check fails and the value is an object with a __type__ field, satisfies looks up the type name in the interpreter’s method_tables. For each method required by the interface, it checks whether the method table contains an entry with that name.

thing Printer {}

give Printer {
    define print_line(it, text) {
        say text
    }
}

power Printable {
    fn print_line(it, text: String)
}

let p = Printer {}
satisfies(p, Printable)    // true (found in method_tables)

Satisfaction Criteria

The check verifies method presence by name only. It does not verify:

  • Parameter count or parameter types
  • Return types
  • Method body or behavior

A type satisfies an interface if and only if every method name listed in the interface’s methods array has a corresponding entry in the type’s method table.

Explicit vs. Structural

Forge supports both explicit and structural interface satisfaction:

ApproachSyntaxWhen Checked
Explicitgive T the power I { ... }At definition time
Structuralsatisfies(value, I)At call time

Explicit implementation triggers immediate validation and produces clear error messages at the definition site. Structural satisfaction is more flexible but defers errors to the point where satisfies is called.

Both approaches can coexist. A type that explicitly implements an interface will also pass satisfies checks.

Examples

Satisfied Without Explicit Declaration

thing Duck {
    name: String
}

power Quackable {
    fn quack(it) -> String
}

give Duck {
    define quack(it) {
        return it.name + " says quack!"
    }
}

let d = Duck { name: "Donald" }
satisfies(d, Quackable)    // true — Duck has quack()

Not Satisfied

thing Rock {
    weight: Int
}

satisfies(Rock { weight: 5 }, Quackable)    // false — Rock has no quack()

Multiple Interface Methods

power Serializable {
    fn to_string(it) -> String
    fn to_json(it) -> String
}

thing Config {
    data: String
}

give Config {
    define to_string(it) { return it.data }
    // Missing to_json
}

let c = Config { data: "test" }
satisfies(c, Serializable)    // false — missing to_json

Return Value

satisfies always returns a Bool value. It never throws an error for non-matching types; it simply returns false. It only raises a runtime error if called with the wrong number of arguments.

Default Values

Struct fields may specify default values that are applied when the field is omitted during construction. Default expressions are evaluated at definition time and stored in the interpreter’s struct_defaults table.

Syntax

FieldWithDefault = Identifier (":" TypeAnnotation)? "=" Expression

The default value follows an = sign after the field name and optional type annotation.

Defining Defaults

thing Config {
    host: String = "localhost",
    port: Int = 8080,
    debug: Bool = false
}

A field may have a type annotation, a default, both, or neither:

Field FormExample
Name onlydata
Name + typedata: String
Name + defaultdata = "hello"
Name + type + defaultdata: String = "hello"

Evaluation Timing

Default expressions are evaluated at definition time — when the thing/struct statement is executed, not when an instance is constructed. This means:

let counter = 0

thing Widget {
    id: Int = counter
}

change counter to 10

let w = Widget {}
say w.id    // 0, not 10 — default was captured at definition time

The default value is the result of evaluating the expression at the point where the struct is defined. Subsequent changes to variables referenced in the default expression do not affect the stored default.

Application During Construction

When a struct instance is constructed, defaults are applied first, then explicitly provided fields override them:

  1. The interpreter retrieves the defaults from struct_defaults[StructName].
  2. All default key-value pairs are inserted into the new object.
  3. Explicitly provided fields in the constructor are evaluated and inserted, overwriting any defaults with the same key.
  4. The __type__ field is inserted last.
thing Server {
    host: String = "0.0.0.0",
    port: Int = 3000,
    workers: Int = 4
}

// All defaults
let s1 = Server {}
// s1 = { host: "0.0.0.0", port: 3000, workers: 4, __type__: "Server" }

// Partial override
let s2 = Server { port: 8080 }
// s2 = { host: "0.0.0.0", port: 8080, workers: 4, __type__: "Server" }

// Full override
let s3 = Server { host: "127.0.0.1", port: 443, workers: 16 }
// s3 = { host: "127.0.0.1", port: 443, workers: 16, __type__: "Server" }

Default Expressions

Default values may be any valid expression, not just literals. They are evaluated in the current scope at definition time:

let default_name = "World"

thing Greeter {
    greeting: String = "Hello, " + default_name + "!"
}

let g = Greeter {}
say g.greeting    // "Hello, World!"

Function calls, arithmetic, string concatenation, and other expressions are all valid defaults.

Storage

The interpreter stores defaults in a struct_defaults table:

struct_defaults: HashMap<String, IndexMap<String, Value>>

The outer key is the struct name. The inner IndexMap maps field names to their default Value. Only fields with defaults are stored; fields without defaults are absent from the map.

Fields Without Defaults

Fields without defaults must be provided during construction. If a field without a default is omitted, the constructed object simply will not have that field — no error is raised at construction time, but accessing the missing field later will produce a runtime error.

Static Methods

Static methods are methods attached to a type that do not operate on an instance. They are defined in give/impl blocks without it as the first parameter and are called on the type name itself.

Syntax

Static methods are defined like instance methods but without it:

give TypeName {
    define method_name(params) {
        // body — no `it` parameter
    }
}

They are called on the type name:

TypeName.method_name(args)

Defining Static Methods

A method is classified as static when its first parameter is not named it. Any other parameter name (or no parameters at all) makes it a static method.

thing Person {
    name: String,
    age: Int
}

give Person {
    // Static method — no `it` parameter
    define species() {
        return "Homo sapiens"
    }

    // Static method — first param is not `it`
    define create(name, age) {
        return Person { name: name, age: age }
    }

    // Instance method — first param IS `it`
    define greet(it) {
        say "Hello, I'm " + it.name
    }
}

Invocation

Static methods are called using the type name with dot notation:

Person.species()               // "Homo sapiens"
let p = Person.create("Bob", 25)  // Person { name: "Bob", age: 25 }

The runtime resolves Person.method() by:

  1. Evaluating Person — this yields the BuiltIn("struct:Person") sentinel value registered during struct definition.
  2. Extracting the type name "Person" from the sentinel tag.
  3. Looking up the method name in static_methods["Person"].
  4. Calling the function with the provided arguments (no instance prepended).

Storage

Static methods are stored in the static_methods table:

static_methods: HashMap<String, IndexMap<String, Value>>

The key is the type name. The value is an IndexMap mapping method names to function values.

When a give/impl block is executed, each method is checked for the it parameter:

First ParameterStored InCall Syntax
itmethod_tables onlyinstance.method()
Anything elseBoth method_tables and static_methodsTypeName.method()

Static methods are stored in both tables. This means they appear in method_tables as well, which allows them to be found during interface satisfaction checks.

Factory Pattern

A common use of static methods is the factory pattern — creating instances with validation or transformation logic:

thing Color {
    r: Int,
    g: Int,
    b: Int
}

give Color {
    define from_hex(hex) {
        // Parse hex string to RGB values
        return Color { r: 0, g: 0, b: 0 }
    }

    define red() {
        return Color { r: 255, g: 0, b: 0 }
    }

    define display(it) {
        return "rgb(" + str(it.r) + ", " + str(it.g) + ", " + str(it.b) + ")"
    }
}

let c = Color.red()
say c.display()    // "rgb(255, 0, 0)"

Additive Blocks

Like instance methods, static methods from multiple give/impl blocks are additive:

give Math {
    define add(a, b) { return a + b }
}

give Math {
    define sub(a, b) { return a - b }
}

Math.add(1, 2)    // 3
Math.sub(5, 3)    // 2

Instance vs. Static Ambiguity

The classification is based solely on whether the first parameter is named it. A method named it with a different first parameter name is static:

give Config {
    // Static: first param is "key", not "it"
    define from_key(key) {
        return Config { value: key }
    }

    // Instance: first param is "it"
    define to_string(it) {
        return str(it.value)
    }
}

Error Handling

This chapter defines Forge’s error handling mechanisms. Forge uses a multi-layered approach: Result types for explicit error values, the ? operator for propagation, safe blocks for error suppression, must for crash-on-error semantics, and check for declarative validation.

Overview

Forge provides five complementary error handling mechanisms:

MechanismPurposeBehavior on Error
Result typeRepresent success/failure as valuesCarries error as data
? operatorPropagate errors up the call stackReturns Err from enclosing function
safe { }Suppress errors silentlyReturns null
must exprAssert success or crashRaises a runtime error
check exprDeclarative validationRaises a runtime error with description

These mechanisms serve different use cases:

  • Result + ? — For functions that can fail and callers that want to handle failures explicitly.
  • safe — For optional operations where failure is acceptable and the value can be null.
  • must — For operations that should never fail in correct code.
  • check — For input validation with readable error messages.

Runtime Errors

All Forge runtime errors are represented by RuntimeError, which contains a message string and an optional propagated value. When an error is not caught, it terminates the program with an error message.

Errors can be caught with try/catch:

try {
    let x = 1 / 0
} catch e {
    say e.message    // "division by zero"
    say e.type       // "ArithmeticError"
}

The catch variable receives an object with message and type fields. Error types are inferred from the error message content:

Error TypeTriggered By
TypeErrorMessage contains “type” or “Type”
ArithmeticErrorMessage contains “division by zero”
AssertionErrorMessage contains “assertion”
IndexErrorMessage contains “index” or “out of bounds”
ReferenceErrorMessage contains “not found” or “undefined”
RuntimeErrorAll other errors

Subsections

The following subsections define each error handling mechanism in detail:

Result Type

The Result type represents the outcome of an operation that may succeed or fail. A Result is either Ok(value) for success or Err(message) for failure. Results are first-class values that can be stored in variables, passed to functions, and returned from functions.

Syntax

ResultOk    = ("Ok" | "ok") "(" Expression ")"
ResultErr   = ("Err" | "err") "(" Expression ")"

Constructors

Forge provides two constructors for creating Result values. Both accept case-insensitive names:

let success = Ok(42)
let failure = Err("something went wrong")

// Lowercase variants are equivalent
let success2 = ok(42)
let failure2 = err("not found")

Ok wraps any value:

Ok(42)            // Result containing Int
Ok("hello")       // Result containing String
Ok([1, 2, 3])     // Result containing Array
Ok(null)          // Result containing null (Ok with no meaningful value)

Err wraps an error value (typically a string message):

Err("file not found")
Err("invalid input: expected number")

If Ok is called with no arguments, it wraps null. If Err is called with no arguments, it wraps the string "error".

Runtime Representation

Results are distinct variants in the Value enum:

  • Value::ResultOk(Box<Value>) — wraps the success value.
  • Value::ResultErr(Box<Value>) — wraps the error value.

The typeof builtin returns "Result" for both variants:

typeof(Ok(1))     // "Result"
typeof(Err("x"))  // "Result"

Display Format

Results are displayed as Ok(value) or Err(value):

say Ok(42)              // Ok(42)
say Err("not found")    // Err(not found)

In JSON serialization, Results produce { "Ok": value } or { "Err": value }.

Inspection Functions

Four builtin functions inspect and extract Result values:

is_ok(result)

Returns true if the value is Ok, false if Err. Raises a runtime error if the argument is not a Result.

is_ok(Ok(42))           // true
is_ok(Err("oops"))      // false

is_err(result)

Returns true if the value is Err, false if Ok. Raises a runtime error if the argument is not a Result.

is_err(Ok(42))          // false
is_err(Err("oops"))     // true

unwrap(result)

Extracts the inner value from Ok. If the value is Err, raises a runtime error with the message "unwrap() on Err: <error_value>".

unwrap(Ok(42))          // 42
unwrap(Err("oops"))     // runtime error: unwrap() on Err: oops

unwrap also works with Option values (Some/None):

unwrap(Some(42))        // 42
unwrap(None)            // runtime error: unwrap() called on None

unwrap_or(result, default)

Extracts the inner value from Ok. If the value is Err, returns the default value instead. Never raises an error for valid Result inputs.

unwrap_or(Ok(42), 0)       // 42
unwrap_or(Err("oops"), 0)  // 0

unwrap_or also works with Option values:

unwrap_or(Some(42), 0)     // 42
unwrap_or(None, 0)         // 0

Raises a runtime error if called with the wrong number of arguments or if the first argument is not a Result or Option.

Pattern Matching on Results

Results can be matched in match expressions using Ok and Err patterns:

let result = Ok(42)

match result {
    Ok(value) -> say "Got: " + str(value),
    Err(msg) -> say "Error: " + msg
}

Results in Functions

Functions commonly return Results to signal success or failure:

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

let result = divide(10, 0)
if is_err(result) {
    say "Cannot divide: " + unwrap(Err("division by zero"))
}

Equality

Two Ok values are equal if their inner values are equal. Two Err values are equal if their inner values are equal. Ok and Err are never equal to each other:

Ok(42) == Ok(42)          // true
Err("x") == Err("x")     // true
Ok(42) == Err(42)         // false
Ok(1) == Ok(2)            // false

Error Propagation

The ? operator provides concise syntax for propagating errors up the call stack. When applied to a Result value, it unwraps Ok values and short-circuits on Err values by returning the error from the enclosing function.

Syntax

TryExpr = Expression "?"

The ? operator is a postfix unary operator applied to an expression that evaluates to a Result.

Semantics

The ? operator evaluates its operand and inspects the result:

  • If the value is Ok(v), the expression evaluates to v (the inner value is unwrapped).
  • If the value is Err(e), the enclosing function immediately returns Err(e).
  • If the value is neither Ok nor Err, a runtime error is raised: `?` expects Result value (Ok(...) or Err(...)).
fn parse_number(s) {
    if s == "" {
        return Err("empty string")
    }
    return Ok(int(s))
}

fn double_parsed(s) {
    let n = parse_number(s)?    // unwraps Ok or returns Err
    return Ok(n * 2)
}

double_parsed("5")     // Ok(10)
double_parsed("")      // Err("empty string")

Propagation Mechanism

When ? encounters an Err value, it raises a RuntimeError with the propagated field set to the Err value. The runtime distinguishes propagated errors from ordinary runtime errors. When a propagated error reaches a function boundary, the function returns the propagated Err value rather than crashing the program.

The implementation:

  1. Expr::Try(expr) evaluates expr.
  2. If the result is ResultOk(value), returns value.
  3. If the result is ResultErr(err), calls RuntimeError::propagate(ResultErr(err)), which creates a RuntimeError whose propagated field carries the original Err value.
  4. The calling function’s error handler detects the propagated value and converts it back into a return value.

Chaining

The ? operator can be chained across multiple function calls:

fn read_config() {
    let text = read_file("config.json")?
    let parsed = json.parse(text)?
    return Ok(parsed)
}

Each ? either unwraps the Ok value for the next step or short-circuits the entire function with the first Err encountered.

Requirements

The ? operator requires that its operand evaluates to a Result value. Applying ? to a non-Result value (such as an Int, String, or null) raises a runtime error:

let x = 42?    // runtime error: `?` expects Result value (Ok(...) or Err(...))

Usage Patterns

Propagate and Transform

fn load_user(id) {
    let data = fetch_user_data(id)?
    let user = parse_user(data)?
    return Ok(user)
}

Propagate with Fallback

Combine ? with unwrap_or for partial error handling:

fn get_config_or_default() {
    let config = read_config()?            // propagate file errors
    let timeout = unwrap_or(config.timeout, 30)  // fallback for missing field
    return Ok(timeout)
}

Top-Level Handling

At the top level, propagated errors become runtime errors since there is no enclosing function to return from:

// If read_config returns Err, this crashes the program
let config = read_config()?

To handle errors at the top level, use is_ok/is_err checks, unwrap_or, or try/catch blocks instead of ?.

Safe and Must

safe and must provide two ends of the error handling spectrum: safe suppresses errors silently, while must crashes on error with a clear message.

Safe Blocks

Syntax

SafeBlock = "safe" "{" Statement* "}"

The safe keyword introduces a block whose errors are silently suppressed.

Semantics

A safe block executes its body statements. If any statement raises a runtime error, the error is caught and the block evaluates to null. If the block completes successfully, its result is returned normally.

safe {
    let data = json.parse("invalid json")
    say data
}
// No error — block silently returns null

Behavior

Block OutcomeResult
Body completes successfullySignal passes through (value, return, etc.)
Body raises a runtime errornull (error suppressed)

The safe block is a statement, not an expression. It does not produce a value that can be assigned directly. When used for its side effects, it simply prevents errors from propagating:

// Attempt to write a file; ignore errors
safe {
    fs.write("log.txt", "entry")
}

// Execution continues regardless
say "done"

Use Cases

  • Optional side effects (logging, caching) where failure is acceptable.
  • Defensive code around external operations (file I/O, network) that may fail intermittently.
  • Quick prototyping where error handling is deferred.

Caution

safe blocks suppress all errors indiscriminately, including programming bugs, type errors, and logic errors. Overuse of safe can hide real problems. Prefer explicit error handling with Result/? for production code.

Must Expression

Syntax

MustExpr = "must" Expression

The must keyword is a prefix operator applied to an expression.

Semantics

must evaluates its operand and asserts that the result is a successful value:

  • If the value is Ok(v), returns v (unwrapped).
  • If the value is Err(e), raises a runtime error: "must failed: <e>".
  • If the value is null, raises a runtime error: "must failed: got null".
  • For any other value, returns it unchanged.
// Succeeds — unwraps Ok
let value = must Ok(42)       // 42

// Crashes — Err inside must
let value = must Err("oops")  // runtime error: must failed: oops

// Crashes — null inside must
let value = must null          // runtime error: must failed: got null

// Passes through — non-Result, non-null
let value = must 42            // 42

Comparison with unwrap

FunctionOn Ok(v)On Err(e)On nullOn other
unwrap(r)vErrorError (for None)Error
must exprvErrorErrorPass through

The key difference: must passes through non-Result, non-null values unchanged, while unwrap requires a Result or Option value. must is designed for contexts where the expression might return a Result, a plain value, or null.

Use Cases

  • Asserting that a critical operation succeeds:

    let db = must db.open("app.db")
    
  • Unwrapping configuration that should always be present:

    let key = must env.get("API_KEY")
    
  • Failing fast on unexpected null values:

    let user = must find_user(id)
    

Combining Safe and Must

safe and must can be used together for fallback patterns:

// Try the primary source, fall back to default
safe {
    let config = must load_config("primary.json")
    apply_config(config)
}
// If must fails, safe catches it and continues

apply_config(default_config())

However, this pattern is generally better expressed with Result types:

let config = unwrap_or(load_config("primary.json"), default_config())
apply_config(config)

Check

The check statement provides declarative validation with clear error messages. It evaluates a condition and raises a runtime error if the check fails.

Syntax

CheckStmt     = "check" Expression CheckKind
CheckKind     = IsNotEmpty | Contains | Between | IsTrue
IsNotEmpty    = "is" "not" "empty"
Contains      = "contains" Expression
Between       = "is" "between" Expression "and" Expression
IsTrue        = (empty — implicit truth check)

Check Kinds

Forge supports four validation kinds, each producing a specific boolean test:

is not empty

Tests that a value is non-empty. The definition of “empty” depends on the type:

TypeEmpty When
StringLength is 0
ArrayLength is 0
NullAlways empty
Other typesNever empty
let name = "Alice"
check name is not empty    // passes

let empty = ""
check empty is not empty   // runtime error: check failed: "" did not pass validation

contains

Tests that a string contains a substring:

let email = "user@example.com"
check email contains "@"    // passes

let bad = "not-an-email"
check bad contains "@"      // runtime error: check failed

The contains check currently operates on strings only. Both the value and the needle must be strings; other type combinations return false.

is between … and

Tests that a numeric value falls within an inclusive range:

let age = 25
check age is between 0 and 150    // passes

let temp = -10
check temp is between 0 and 100   // runtime error: check failed

Both Int and Float values are supported, but the value and both bounds must be the same type. Mixed-type comparisons (e.g., Int value with Float bounds) return false.

Implicit Truth Check

When no check kind is specified, the value is tested for truthiness:

let valid = true
check valid    // passes

let invalid = false
check invalid  // runtime error: check failed

Error Messages

When a check fails, the runtime raises a RuntimeError with the message:

check failed: <value> did not pass validation

Where <value> is the string representation of the tested value.

Use Cases

Input Validation

fn create_user(name, age, email) {
    check name is not empty
    check age is between 0 and 150
    check email contains "@"

    return { name: name, age: age, email: email }
}

Preconditions

fn withdraw(account, amount) {
    check amount is between 1 and 10000
    check account.balance is between amount and 999999

    account.balance = account.balance - amount
}

Configuration Validation

let config = load_config()
check config.host is not empty
check config.port is between 1 and 65535

Comparison with Assert

Featurecheckassert
PurposeDeclarative validationGeneral assertion
Syntaxcheck expr is not emptyassert(condition, "message")
Error messageAuto-generated from valueUser-provided
Kindsis not empty, contains, is between, truthBoolean only

check is designed for readable validation logic with automatic error descriptions. assert is a general-purpose assertion that requires the programmer to provide an error message.

Nesting in Safe Blocks

Check failures are runtime errors and can be caught by safe blocks or try/catch:

safe {
    check "" is not empty    // fails, but error is suppressed
}
say "continues"              // prints "continues"
try {
    check input is not empty
} catch e {
    say "Validation error: " + e.message
}

Concurrency

This chapter defines Forge’s concurrency primitives. Forge provides three mechanisms for concurrent execution: channels for message passing, spawn for task creation, and async/await (with natural syntax aliases forge/hold) for asynchronous functions.

Overview

Forge’s concurrency model is built on OS threads (via std::thread) with channels for communication:

MechanismPurposeSyntax
ChannelsMessage passing between taskschannel(), send(), receive()
SpawnCreate concurrent tasksspawn { body }
Async/AwaitAsynchronous function definition and invocationasync fn / await, forge / hold

Execution Model

Forge spawns concurrent tasks as OS threads. Each spawned task receives a clone of the current environment, enabling access to variables defined before the spawn point. Tasks do not share mutable state directly; communication should use channels.

The runtime uses std::thread::spawn for task creation and std::sync::mpsc::sync_channel for channels. This provides:

  • True parallelism on multi-core systems.
  • Thread-safe communication through bounded channels.
  • Task handle values with condition-variable notification for await.

Task Handles

When spawn is used as an expression, it returns a TaskHandle value. Task handles are opaque values that can be passed to await (or hold) to block until the spawned task completes and retrieve its return value.

let handle = spawn { return 42 }
let result = await handle    // 42

Task handles use an Arc<(Mutex<Option<Value>>, Condvar)> internally:

  • The Mutex<Option<Value>> holds the task’s return value (initially None).
  • The Condvar is notified when the task writes its result.
  • await blocks on the Condvar until the result is available, then extracts it.

Error Isolation

Errors in spawned tasks do not crash the parent task. If a spawned task encounters a runtime error, the error is printed to stderr and the task’s result is null:

spawn { let x = 1 / 0 }    // prints error to stderr, does not crash parent
say "still running"          // executes normally

Subsections

The following subsections define each concurrency mechanism in detail:

  • Channels — Message passing between tasks.
  • Spawn — Creating concurrent tasks.
  • Async Functions — Defining asynchronous functions.
  • Await — Waiting for async results.

Channels

Channels provide thread-safe message passing between concurrent tasks. A channel is a bounded, synchronous queue that allows one task to send values and another to receive them.

Creating a Channel

Syntax

channel()
channel(capacity)

The channel builtin creates a new channel and returns a Channel value. An optional integer argument specifies the buffer capacity (default: 32).

let ch = channel()        // buffered channel, capacity 32
let ch = channel(100)     // buffered channel, capacity 100
let ch = channel(1)       // minimal buffer, capacity 1

If a non-integer argument is provided, the default capacity of 32 is used. The minimum capacity is 1 (values less than 1 are clamped to 1).

Channel Value

A channel is represented at runtime as Value::Channel(Arc<ChannelInner>). The ChannelInner struct contains:

  • tx: Mutex<Option<SyncSender<Value>>> — the sender half.
  • rx: Mutex<Option<Receiver<Value>>> — the receiver half.
  • capacity: usize — the buffer capacity.

Channels are reference-counted via Arc, so they can be safely shared between the parent task and spawned tasks through environment cloning.

Sending Values

send(channel, value)

The send builtin sends a value through a channel. It blocks if the channel buffer is full, waiting until a receiver consumes a value.

let ch = channel()
send(ch, 42)
send(ch, "hello")
send(ch, [1, 2, 3])

Any Forge value can be sent through a channel: integers, strings, arrays, objects, functions, Results, and other channels.

Arguments:

  • channel — A Channel value (first argument).
  • value — Any Value to send (second argument).

Returns: null on success.

Errors:

  • "send(channel, value) requires 2 arguments" — Wrong argument count.
  • "send() requires a channel as first argument" — First argument is not a channel.
  • "channel closed" — The receiver has been dropped.

try_send(channel, value)

The try_send builtin attempts to send a value without blocking. Returns true if the value was sent, false if the channel is full or closed.

let ch = channel(1)
send(ch, "first")            // fills the buffer
let ok = try_send(ch, "second")  // false — buffer is full

Arguments: Same as send.

Returns: Booltrue if sent, false otherwise.

Errors:

  • "try_send() requires (channel, value)" — Wrong argument count.
  • "try_send() first argument must be a channel" — First argument is not a channel.

Receiving Values

receive(channel)

The receive builtin receives a value from a channel. It blocks until a value is available.

let ch = channel()
send(ch, 42)
let val = receive(ch)    // 42

If the channel is closed (all senders dropped) and the buffer is empty, receive returns null.

Arguments:

  • channel — A Channel value.

Returns: The received Value, or null if the channel is closed.

Errors:

  • "receive(channel) requires 1 argument" — No argument provided.
  • "receive() requires a channel as first argument" — Argument is not a channel.

try_receive(channel)

The try_receive builtin attempts to receive a value without blocking. Returns Some(value) if a value was available, None if the channel is empty.

let ch = channel()
let result = try_receive(ch)    // None — nothing sent yet

send(ch, 42)
let result = try_receive(ch)    // Some(42)

Arguments:

  • channel — A Channel value.

Returns: Some(value) if a value was received, None if the channel is empty or closed.

Errors:

  • "try_receive() requires a channel" — No argument provided.
  • "try_receive() argument must be a channel" — Argument is not a channel.

Producer-Consumer Pattern

Channels enable classic producer-consumer patterns:

let ch = channel()

// Producer
spawn {
    repeat 5 times {
        send(ch, it)
    }
}

// Consumer
repeat 5 times {
    let val = receive(ch)
    say "got: " + str(val)
}

Fan-Out Pattern

Multiple consumers can share a channel, though only one will receive each message:

let work = channel()
let results = channel()

// Producer
spawn {
    for item in tasks {
        send(work, item)
    }
}

// Workers
repeat 3 times {
    spawn {
        let item = receive(work)
        let result = process(item)
        send(results, result)
    }
}

Channel Lifetime

Channels remain open as long as at least one reference exists. When all references to a channel are dropped (through garbage collection or scope exit), the underlying SyncSender and Receiver are dropped, which closes the channel. Subsequent send calls on a closed channel return an error; receive calls return null.

Spawn

The spawn keyword creates a concurrent task that runs in a separate OS thread. It can be used as a statement (fire-and-forget) or as an expression (returning a task handle).

Syntax

SpawnStmt = "spawn" Block
SpawnExpr = "spawn" Block

When spawn appears as a statement, the result is discarded. When it appears as an expression (e.g., assigned to a variable), it returns a TaskHandle.

Statement Form (Fire-and-Forget)

When spawn is used as a statement, the block is executed concurrently and its result is discarded:

spawn {
    say "running in background"
}
say "continues immediately"

The parent does not wait for the spawned task to complete. The spawned block runs independently.

Expression Form (Task Handle)

When spawn is used as an expression, it returns a TaskHandle that can be awaited:

let handle = spawn {
    return 42
}

let result = await handle    // 42

The task handle is an opaque value that represents the running task. Its type name is "TaskHandle".

Execution Model

When spawn is executed:

  1. The block’s statements are cloned.
  2. A new Interpreter is created and its environment is cloned from the parent.
  3. A shared result slot is created: Arc<(Mutex<Option<Value>>, Condvar)>.
  4. A new OS thread is spawned via std::thread::spawn.
  5. The thread executes the block. When it completes:
    • Signal::Return(v) or Signal::ImplicitReturn(v) stores v in the result slot.
    • Signal::None or other signals store null.
    • Errors print to stderr and store null.
  6. The Condvar is notified, unblocking any await on the handle.
  7. The TaskHandle value is returned to the parent.

Environment Cloning

The spawned task receives a clone of the parent’s environment at the point of the spawn call. This means:

  • Variables defined before spawn are accessible in the spawned block.
  • Modifications to the environment inside the spawned block do not affect the parent.
  • Modifications in the parent after spawn do not affect the spawned block.
let x = 10
spawn {
    say x        // 10 — sees parent's x
    let x = 20   // shadows, does not affect parent
}
say x            // 10 — parent's x unchanged

Return Values

The spawned block may use return to provide a result. This value is stored in the task handle’s result slot:

let h = spawn {
    return "hello from spawn"
}
let msg = await h    // "hello from spawn"

If no return is used, the result is the last expression value (implicit return) or null:

let h = spawn {
    1 + 1
}
let result = await h    // may be 2 or null depending on block signal

Error Isolation

Errors in spawned tasks are isolated from the parent. A runtime error in the spawned block:

  1. Prints the error message to stderr: spawn error: <message>.
  2. Stores null in the result slot.
  3. Does not crash or affect the parent task.
spawn {
    let x = 1 / 0    // error: division by zero
}
// Parent continues normally
say "still running"

When the handle is awaited, the result is null:

let h = spawn {
    return 1 / 0
}
let result = await h    // null (error was caught internally)

Multiple Spawns

Multiple tasks can be spawned and awaited:

let a = spawn { return 10 }
let b = spawn { return 20 }

let va = await a    // 10
let vb = await b    // 20
say va + vb         // 30

Tasks run concurrently in separate threads. The order of completion is non-deterministic.

Spawn with Channels

Spawn and channels work together for structured concurrency:

let ch = channel()

spawn {
    let result = expensive_computation()
    send(ch, result)
}

// Do other work...

let result = receive(ch)    // blocks until computation completes

Async Functions

Async functions are functions that execute asynchronously and must be awaited to retrieve their result. Forge provides dual syntax: async fn (classic) and forge (natural).

Syntax

AsyncFnDef    = ("async" "fn" | "forge") Identifier "(" ParamList ")" Block

Defining Async Functions

Classic syntax:

async fn fetch_data() {
    let resp = http.get("https://api.example.com/data")
    return resp
}

Natural syntax:

forge fetch_data() {
    let resp = http.get("https://api.example.com/data")
    return resp
}

Both forms are semantically identical. The parser produces the same FnDef AST node with is_async: true.

Semantics

An async function is defined with the is_async flag set to true in the AST. In the current implementation, async functions are stored as regular function values. The is_async flag is recorded in the AST but the interpreter treats async functions identically to synchronous functions during definition.

The distinction becomes relevant at the call site: async functions are expected to be called with await (or hold) to retrieve their result. Without await, the function executes synchronously and its return value is available immediately.

Registration

When an async function definition is executed, the function is registered in the environment as a Value::Function just like a synchronous function. The is_async flag from the AST does not alter the stored function value.

forge get_value() {
    return 42
}

// The function is callable like any other function
let result = get_value()       // 42 (runs synchronously)
let result = await get_value() // 42 (await passes through non-handle values)

Parameters and Return Values

Async functions support the same parameter syntax as regular functions, including default values and type annotations:

async fn fetch_user(id: Int, timeout: Int = 30) {
    let resp = http.get("https://api.example.com/users/" + str(id))
    return json.parse(resp.body)
}

Return values follow the same rules as synchronous functions. The return statement provides an explicit return value; without it, the function returns null or the last expression’s value.

Combining with Spawn

For true concurrent execution, combine async functions with spawn:

forge compute(n) {
    // Expensive computation
    return n * n
}

let handle = spawn { return compute(42) }
let result = await handle    // 1764

The spawn keyword is what creates actual concurrency (a new OS thread). The async/forge keyword marks intent but does not itself create a new thread.

Natural Syntax: forge

The forge keyword serves double duty as both the language name and the natural-syntax alias for async fn. In a function definition context, forge is parsed as an async function definition:

forge load_config() {
    let text = fs.read("config.json")
    return json.parse(text)
}

This reads naturally as “forge a load_config function” while being functionally equivalent to async fn load_config().

Await

The await keyword (or its natural alias hold) suspends the current execution until an asynchronous operation completes, then returns the result. It is primarily used with task handles from spawn.

Syntax

AwaitExpr = ("await" | "hold") Expression

Semantics

The await expression evaluates its operand and inspects the result:

TaskHandle

If the operand is a TaskHandle (returned by spawn), await blocks the current thread until the spawned task completes, then returns the task’s result value.

let h = spawn { return 42 }
let result = await h    // blocks until task completes, returns 42

The blocking mechanism uses a condition variable:

  1. Lock the Mutex<Option<Value>> inside the task handle.
  2. While the value is None, wait on the Condvar.
  3. When notified (the spawned task stored its result), extract the value.
  4. Return the extracted value, or null if the slot was empty.

Non-Handle Values (Pass-Through)

If the operand is not a TaskHandle, await returns the value unchanged. This provides backward compatibility and allows await to be used uniformly:

await 42         // 42
await "hello"    // "hello"
await null       // null
await Ok(10)     // Ok(10)

This pass-through behavior means await is always safe to call, even on values that are not async results.

Natural Syntax: hold

The hold keyword is the natural-syntax alias for await:

let h = spawn { return "data" }
let result = hold h    // "data"

hold and await are parsed to the same Expr::Await AST node and behave identically.

Awaiting Multiple Tasks

Multiple task handles can be awaited sequentially:

let a = spawn { return 10 }
let b = spawn { return 20 }
let c = spawn { return 30 }

let va = await a    // 10
let vb = await b    // 20
let vc = await c    // 30

say va + vb + vc    // 60

Each await blocks until its specific task completes. Tasks run concurrently, so the total time is approximately the duration of the slowest task, not the sum.

Awaiting Errored Tasks

If a spawned task encountered a runtime error, its result slot contains null. Awaiting such a handle returns null:

let h = spawn {
    return 1 / 0    // runtime error
}

let result = await h    // null

The error is printed to stderr by the spawned task. The parent receives null and must check for it if error detection is needed.

Await in Functions

await can be used inside regular and async functions:

fn parallel_sum(a, b) {
    let ha = spawn { return a * a }
    let hb = spawn { return b * b }
    return await ha + await hb
}

parallel_sum(3, 4)    // 25

Error Handling

The await expression can raise runtime errors in two cases:

  • "await: task handle lock poisoned" — The Mutex guarding the result slot was poisoned (the spawned thread panicked while holding the lock).
  • "await: condvar wait failed" — The condition variable wait failed.

Both are exceptional conditions that indicate a serious runtime problem.

Comparison with hold

SyntaxKeywordParsingBehavior
Classicawait exprExpr::Await(expr)Block until result
Naturalhold exprExpr::Await(expr)Block until result

There is no semantic difference. Use whichever style matches your code’s convention.

Standard Library Overview

Forge ships with 16 built-in modules containing over 230 functions. All modules are available without any import statement. There is no import math or require("fs") – every module is pre-loaded into the global scope.

Accessing Modules

Modules are accessed via dot notation:

let root = math.sqrt(144)       // 12.0
let data = fs.read("config.json")
let hash = crypto.sha256("hello")

Each module is a first-class object. You can assign it to a variable:

let m = math
say m.pi    // 3.141592653589793

Module Index

ModuleDescriptionFunctions
mathMathematical operations and constants17
fsFile system operations20
ioInput/output and command-line arguments6
cryptoHashing, encoding, and decoding6
dbSQLite database operations4
pgPostgreSQL database operations4
jsonJSON parsing and serialization3
csvCSV parsing and serialization4
regexRegular expression matching5
envEnvironment variables4
logStructured logging with timestamps4
termTerminal colors, formatting, and widgets25+
httpHTTP client and server decorators9
execExternal command execution1
timeDate, time, and timezone operations25
npcFake data generation for testing16

Execution Tier Support

All modules are fully supported in the interpreter (default execution mode). The bytecode VM (--vm) and JIT (--jit) support a subset of modules – primarily math, fs, io, and npc. For full module access, use the interpreter.

math

Mathematical operations and constants. All trigonometric functions use radians.

Constants

NameTypeValue
math.pifloat3.141592653589793
math.efloat2.718281828459045
math.inffloatInfinity
say math.pi    // 3.141592653589793
say math.e     // 2.718281828459045

Functions

math.sqrt(n) -> float

Returns the square root of n.

math.sqrt(144)   // 12.0
math.sqrt(2)     // 1.4142135623730951

math.pow(base, exp) -> int | float

Returns base raised to the power of exp. Returns int when both arguments are non-negative integers; returns float otherwise.

math.pow(2, 10)    // 1024
math.pow(2.0, 0.5) // 1.4142135623730951
math.pow(2, -1)    // 0.5

math.abs(n) -> int | float

Returns the absolute value of n. Preserves the input type.

math.abs(-42)    // 42
math.abs(-3.14)  // 3.14

math.max(a, b) -> int | float

Returns the greater of a and b.

math.max(10, 20)     // 20
math.max(3.14, 2.71) // 3.14

math.min(a, b) -> int | float

Returns the lesser of a and b.

math.min(10, 20)     // 10
math.min(3.14, 2.71) // 2.71

math.floor(n) -> int

Returns the largest integer less than or equal to n.

math.floor(3.7)   // 3
math.floor(-1.2)  // -2
math.floor(5)     // 5

math.ceil(n) -> int

Returns the smallest integer greater than or equal to n.

math.ceil(3.2)    // 4
math.ceil(-1.8)   // -1
math.ceil(5)      // 5

math.round(n) -> int

Returns the nearest integer to n, rounding half away from zero.

math.round(3.5)   // 4
math.round(3.4)   // 3
math.round(-2.5)  // -3

math.random() -> float

Returns a pseudo-random float between 0.0 and 1.0 (exclusive).

let r = math.random()  // e.g. 0.482371...

math.random_int(min, max) -> int

Returns a pseudo-random integer in the inclusive range [min, max]. Errors if min > max.

let die = math.random_int(1, 6)   // 1-6
let coin = math.random_int(0, 1)  // 0 or 1

math.sin(n) -> float

Returns the sine of n (in radians).

math.sin(0)          // 0.0
math.sin(math.pi/2)  // 1.0

math.cos(n) -> float

Returns the cosine of n (in radians).

math.cos(0)       // 1.0
math.cos(math.pi) // -1.0

math.tan(n) -> float

Returns the tangent of n (in radians).

math.tan(0)          // 0.0
math.tan(math.pi/4)  // ~1.0

math.log(n) -> float

Returns the natural logarithm (base e) of n.

math.log(1)       // 0.0
math.log(math.e)  // 1.0

math.clamp(value, min, max) -> int | float

Clamps value to the range [min, max].

math.clamp(5, 1, 10)    // 5
math.clamp(-5, 0, 10)   // 0
math.clamp(15, 0, 10)   // 10

fs

File system operations. All paths are strings. Functions that write to the file system return null on success.

Functions

fs.read(path) -> string

Reads the entire file at path and returns its contents as a string.

let content = fs.read("config.txt")
say content

fs.write(path, content) -> null

Writes content to the file at path, creating or overwriting the file.

fs.write("output.txt", "Hello, world!")

fs.append(path, content) -> null

Appends content to the file at path. Creates the file if it does not exist.

fs.append("log.txt", "New log entry\n")

fs.exists(path) -> bool

Returns true if a file or directory exists at path.

if fs.exists("config.json") {
    say "Config found"
}

fs.list(path) -> array

Returns an array of file and directory names in the directory at path. Names only, not full paths.

let files = fs.list("./src")
// ["main.fg", "utils.fg", "lib"]

fs.remove(path) -> null

Deletes a file or directory (recursively) at path.

fs.remove("temp.txt")
fs.remove("build/")     // removes directory and all contents

fs.mkdir(path) -> null

Creates the directory at path, including any necessary parent directories.

fs.mkdir("build/output/logs")

fs.copy(source, destination) -> int

Copies a file from source to destination. Returns the number of bytes copied.

let bytes = fs.copy("original.txt", "backup.txt")
say bytes  // e.g. 1024

fs.rename(old_path, new_path) -> null

Renames or moves a file or directory.

fs.rename("draft.txt", "final.txt")

fs.size(path) -> int

Returns the size of the file at path in bytes.

let s = fs.size("data.bin")
say s  // e.g. 4096

fs.ext(path) -> string

Returns the file extension without the leading dot. Returns an empty string if none.

fs.ext("photo.png")     // "png"
fs.ext("Makefile")      // ""

fs.read_json(path) -> any

Reads a JSON file and returns the parsed Forge value (object, array, etc.).

let config = fs.read_json("config.json")
say config.name

fs.write_json(path, value) -> null

Serializes value as pretty-printed JSON and writes it to path.

let data = { name: "forge", version: "0.3.3" }
fs.write_json("package.json", data)

fs.lines(path) -> array

Reads a file and returns an array of strings, one per line.

let lines = fs.lines("data.csv")
say len(lines)  // number of lines

fs.dirname(path) -> string

Returns the directory portion of path.

fs.dirname("/home/user/file.txt")  // "/home/user"

fs.basename(path) -> string

Returns the file name portion of path.

fs.basename("/home/user/file.txt")  // "file.txt"

fs.join_path(a, b) -> string

Joins two path segments with the platform path separator.

fs.join_path("/home", "user")  // "/home/user"

fs.is_dir(path) -> bool

Returns true if path is a directory.

fs.is_dir("/tmp")      // true
fs.is_dir("file.txt")  // false

fs.is_file(path) -> bool

Returns true if path is a regular file.

fs.is_file("main.fg")  // true
fs.is_file("/tmp")      // false

fs.temp_dir() -> string

Returns the path to the system temporary directory.

let tmp = fs.temp_dir()
say tmp  // e.g. "/tmp"

crypto

Hashing and encoding utilities. All functions accept and return strings.

Functions

crypto.sha256(input) -> string

Returns the SHA-256 hash of input as a lowercase hex string.

crypto.sha256("hello")
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

crypto.md5(input) -> string

Returns the MD5 hash of input as a lowercase hex string.

crypto.md5("hello")
// "5d41402abc4b2a76b9719d911017c592"

Note: MD5 is cryptographically broken. Use crypto.sha256 for security-sensitive hashing. MD5 is provided for compatibility and checksums only.

crypto.base64_encode(input) -> string

Encodes input as a Base64 string using the standard alphabet.

crypto.base64_encode("hello world")
// "aGVsbG8gd29ybGQ="

crypto.base64_decode(input) -> string

Decodes a Base64 string back to its original form.

crypto.base64_decode("aGVsbG8gd29ybGQ=")
// "hello world"

crypto.hex_encode(input) -> string

Encodes input as a hexadecimal string.

crypto.hex_encode("AB")
// "4142"

crypto.hex_decode(input) -> string

Decodes a hexadecimal string back to its original form.

crypto.hex_decode("4142")
// "AB"

db

SQLite database operations. Forge embeds SQLite via the rusqlite crate. One connection is maintained per thread.

Functions

db.open(path) -> bool

Opens a SQLite database at path. Use ":memory:" for an in-memory database. Returns true on success.

db.open(":memory:")
db.open("app.db")

db.execute(sql, params?) -> null

Executes a SQL statement that does not return rows (CREATE, INSERT, UPDATE, DELETE). An optional second argument provides parameterized values as an array, using ? placeholders.

db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")

// Parameterized insert (recommended)
db.execute("INSERT INTO users VALUES (?, ?, ?)", [1, "Alice", "alice@example.com"])

// Batch execution (no params)
db.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@example.com')")

db.query(sql, params?) -> array

Executes a SQL SELECT query and returns an array of objects. Each object maps column names to values. An optional second argument provides parameterized values.

let users = db.query("SELECT * FROM users")
// [{id: 1, name: "Alice", email: "alice@example.com"}, ...]

// Parameterized query
let result = db.query("SELECT * FROM users WHERE id = ?", [1])
say result[0].name  // "Alice"

db.close() -> null

Closes the current database connection.

db.close()

Full CRUD Example

// Open database
db.open(":memory:")

// Create table
db.execute("CREATE TABLE tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done INTEGER DEFAULT 0
)")

// Create
db.execute("INSERT INTO tasks (title) VALUES (?)", ["Buy groceries"])
db.execute("INSERT INTO tasks (title) VALUES (?)", ["Write documentation"])
db.execute("INSERT INTO tasks (title) VALUES (?)", ["Deploy v2"])

// Read
let all_tasks = db.query("SELECT * FROM tasks")
say all_tasks

let pending = db.query("SELECT * FROM tasks WHERE done = ?", [0])
say len(pending)  // 3

// Update
db.execute("UPDATE tasks SET done = ? WHERE id = ?", [1, 1])

// Delete
db.execute("DELETE FROM tasks WHERE id = ?", [3])

// Verify
let remaining = db.query("SELECT * FROM tasks")
say remaining

// Close
db.close()

Notes

  • SQLite types map to Forge types: INTEGER -> int, REAL -> float, TEXT -> string, NULL -> null, BLOB -> string (as <blob N bytes>).
  • Parameterized queries (using ? placeholders with an array) are the recommended approach to prevent SQL injection.

pg

PostgreSQL database operations. Requires a running PostgreSQL server. Uses tokio-postgres under the hood and requires the async runtime (interpreter mode).

Functions

pg.connect(connection_string) -> bool

Connects to a PostgreSQL database. Returns true on success. The connection string follows the standard PostgreSQL format.

pg.connect("host=localhost user=postgres password=secret dbname=myapp")

pg.query(sql) -> array

Executes a SQL SELECT query and returns an array of objects. Each object maps column names to values.

let users = pg.query("SELECT id, name, email FROM users")
for user in users {
    say user.name
}

pg.execute(sql) -> int

Executes a SQL statement that does not return rows. Returns the number of rows affected.

let count = pg.execute("UPDATE users SET active = true WHERE last_login > now() - interval '30 days'")
say count  // number of rows updated

pg.close() -> null

Closes the current PostgreSQL connection.

pg.close()

Example

pg.connect("host=localhost dbname=shop user=postgres")

pg.execute("CREATE TABLE IF NOT EXISTS products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(10,2)
)")

pg.execute("INSERT INTO products (name, price) VALUES ('Widget', 9.99)")
pg.execute("INSERT INTO products (name, price) VALUES ('Gadget', 24.99)")

let products = pg.query("SELECT * FROM products ORDER BY price")
for p in products {
    say p.name + " - $" + str(p.price)
}

pg.close()

Notes

  • PostgreSQL types map to Forge types: integer/bigint -> int, real/double precision/numeric -> float, text/varchar -> string, boolean -> bool, NULL -> null.
  • The pg module requires the interpreter (default) execution mode. It is not available in the VM or JIT tiers.

json

JSON parsing and serialization.

Functions

json.parse(string) -> any

Parses a JSON string and returns the corresponding Forge value. Objects become Forge objects, arrays become Forge arrays, and JSON primitives map to their Forge equivalents.

let data = json.parse('{"name": "Forge", "version": 3}')
say data.name     // "Forge"
say data.version   // 3

let arr = json.parse("[1, 2, 3]")
say arr[0]  // 1

json.stringify(value) -> string

Serializes a Forge value into a compact JSON string (no extra whitespace).

let obj = { name: "Forge", tags: ["fast", "fun"] }
let s = json.stringify(obj)
say s  // {"name": "Forge", "tags": ["fast", "fun"]}

json.pretty(value, indent?) -> string

Serializes a Forge value into a pretty-printed JSON string. The optional indent parameter specifies the number of spaces per indentation level (default: 2).

let obj = { name: "Forge", version: 3 }
say json.pretty(obj)
// {
//   "name": "Forge",
//   "version": 3
// }

say json.pretty(obj, 4)
// {
//     "name": "Forge",
//     "version": 3
// }

Type Mapping

JSONForge
nullnull
true / falsebool
integer numberint
floating-point numberfloat
"string"string
[...]array
{...}object

csv

CSV parsing and serialization. Uses comma-separated values with automatic type inference for fields.

Functions

csv.parse(string) -> array

Parses a CSV string into an array of objects. The first line is treated as headers. Values are automatically converted to int, float, or bool where possible; otherwise they remain strings.

let data = csv.parse("name,age,active\nAlice,30,true\nBob,25,false")
say data[0].name    // "Alice"
say data[0].age     // 30
say data[1].active  // false

csv.stringify(rows) -> string

Converts an array of objects into a CSV string. Headers are derived from the keys of the first object.

let rows = [
    { name: "Alice", age: 30 },
    { name: "Bob", age: 25 }
]
let output = csv.stringify(rows)
say output
// name,age
// Alice,30
// Bob,25

Values containing commas or quotes are automatically quoted.

csv.read(path) -> array

Reads a CSV file from disk and parses it into an array of objects. Equivalent to csv.parse(fs.read(path)).

let users = csv.read("users.csv")
for user in users {
    say user.name + ": " + str(user.email)
}

csv.write(path, rows) -> null

Serializes an array of objects as CSV and writes it to the file at path.

let data = [
    { product: "Widget", price: 9.99, qty: 100 },
    { product: "Gadget", price: 24.99, qty: 50 }
]
csv.write("inventory.csv", data)

regex

Regular expression operations. Uses Rust’s regex crate syntax.

Important: All regex functions take the text first, pattern second: regex.test(text, pattern). This is the opposite of many other languages.

Functions

regex.test(text, pattern) -> bool

Returns true if pattern matches anywhere in text.

regex.test("hello world", "world")     // true
regex.test("hello world", "^world")    // false
regex.test("abc123", "\\d+")           // true

regex.find(text, pattern) -> string | null

Returns the first match of pattern in text, or null if no match.

regex.find("order-4521-confirmed", "\\d+")  // "4521"
regex.find("no numbers here", "\\d+")       // null

regex.find_all(text, pattern) -> array

Returns an array of all non-overlapping matches of pattern in text.

regex.find_all("call 555-1234 or 555-5678", "\\d{3}-\\d{4}")
// ["555-1234", "555-5678"]

regex.find_all("aabbaab", "a+")
// ["aa", "aa"]

regex.replace(text, pattern, replacement) -> string

Replaces all occurrences of pattern in text with replacement.

regex.replace("hello world", "world", "Forge")
// "hello Forge"

regex.replace("2024-01-15", "(\\d{4})-(\\d{2})-(\\d{2})", "$2/$3/$1")
// "01/15/2024"

The replacement string supports capture group references ($1, $2, etc.).

regex.split(text, pattern) -> array

Splits text by occurrences of pattern and returns an array of substrings.

regex.split("one,,two,,,three", ",+")
// ["one", "two", "three"]

regex.split("hello   world  foo", "\\s+")
// ["hello", "world", "foo"]

env

Environment variable access.

Functions

env.get(key, default?) -> string | null

Returns the value of the environment variable key. Returns null if the variable is not set, or default if provided.

let home = env.get("HOME")
say home  // "/Users/alice"

let port = env.get("PORT", "8080")
say port  // "8080" if PORT is not set

env.set(key, value) -> null

Sets the environment variable key to value for the current process.

env.set("APP_MODE", "production")

env.has(key) -> bool

Returns true if the environment variable key is set.

if env.has("DATABASE_URL") {
    say "Database configured"
}

env.keys() -> array

Returns an array of all environment variable names.

let all_keys = env.keys()
say len(all_keys)

log

Structured logging with timestamps and severity levels. All log output is written to stderr with ANSI color formatting.

Functions

log.info(…args) -> null

Logs an informational message in green.

log.info("Server started on port", 8080)
// [14:30:15 INFO]  Server started on port 8080

log.warn(…args) -> null

Logs a warning message in yellow.

log.warn("Disk usage above 80%")
// [14:30:15 WARN]  Disk usage above 80%

log.error(…args) -> null

Logs an error message in red.

log.error("Failed to connect to database:", err)
// [14:30:15 ERROR] Failed to connect to database: connection refused

log.debug(…args) -> null

Logs a debug message in gray. Useful for development diagnostics.

log.debug("Request payload:", data)
// [14:30:15 DEBUG] Request payload: {name: "Alice"}

Notes

  • All functions accept any number of arguments. Arguments are converted to strings and joined with spaces.
  • Timestamps use the local time in HH:MM:SS format.
  • Output goes to stderr, not stdout, so it does not interfere with piped output.

term

Terminal formatting, colors, and UI widgets. Color functions return styled strings; display functions print to stderr and return null.

Color Functions

Each color function wraps text in ANSI escape codes and returns the styled string.

term.red(text) -> string

term.green(text) -> string

term.blue(text) -> string

term.yellow(text) -> string

term.cyan(text) -> string

term.magenta(text) -> string

say term.red("Error!")
say term.green("Success!")
say term.blue("Info")

term.bold(text) -> string

term.dim(text) -> string

say term.bold("Important")
say term.dim("subtle note")

Display Functions

term.table(rows) -> null

Prints a formatted table from an array of objects. Column widths are auto-calculated. Headers come from the keys of the first object.

let data = [
    { name: "Alice", role: "Admin", active: true },
    { name: "Bob", role: "User", active: false }
]
term.table(data)
// name  | role  | active
// ------+-------+-------
// Alice | Admin | true
// Bob   | User  | false

term.hr(width?, char?) -> null

Prints a horizontal rule. Default width is 40, default character is "─".

term.hr()         // ────────────────────────────────────────
term.hr(20)       // ────────────────────
term.hr(20, "=")  // ====================

term.banner(text) -> null

Prints text in a double-line box.

term.banner("Forge v0.3.3")
// ╔════════════════╗
// ║  Forge v0.3.3  ║
// ╚════════════════╝

term.box(text) -> null

Prints text in a single-line box. Supports multi-line text.

term.box("Hello\nWorld")
// ┌───────┐
// │ Hello │
// │ World │
// └───────┘

term.bar(label, value, max?) -> null

Prints a progress bar. Default max is 100.

term.bar("CPU", 73, 100)
//   CPU [██████████████████████░░░░░░░░] 73%

term.sparkline(numbers) -> string

Returns a sparkline string from an array of numbers using Unicode block characters.

let spark = term.sparkline([1, 5, 3, 8, 2, 7, 4, 6])
say spark  // ▁▅▃█▂▇▃▆

term.gradient(text) -> string

Returns text with a rainbow gradient using 256-color ANSI codes.

say term.gradient("Hello, Forge!")

term.success(message) -> null

Prints a green success message with a checkmark.

term.success("Build complete")
//   ✅ Build complete

term.error(message) -> null

Prints a red error message with an X mark.

term.error("Compilation failed")
//   ❌ Compilation failed

term.warning(message) -> null

Prints a yellow warning message.

term.warning("Deprecated API usage")

term.info(message) -> null

Prints a cyan info message.

term.info("3 files processed")

term.clear() -> null

Clears the terminal screen.

term.confirm(prompt?) -> bool

Prints a yes/no prompt and returns true if the user enters “y” or “yes”.

if term.confirm("Delete all files?") {
    fs.remove("output/")
}

term.menu(options, prompt?) -> any

Displays a numbered menu and returns the selected item.

let choice = term.menu(["New Project", "Open Project", "Quit"])
say choice

term.countdown(seconds?) -> null

Displays an animated countdown. Default is 3 seconds.

term.countdown(5)

term.typewriter(text, delay?) -> null

Prints text one character at a time. Default delay is 30ms per character.

term.typewriter("Loading system...", 50)

term.emoji(name) -> string

Returns an emoji by name. Use term.emojis() to list all available names.

say term.emoji("rocket")  // 🚀
say term.emoji("check")   // ✅
say term.emoji("fire")    // 🔥

term.beep() -> null

Plays the terminal bell sound.

http

HTTP client for making requests and a decorator-based HTTP server built on axum + tokio.

Client Functions

All client functions return a response object with the following fields:

FieldTypeDescription
statusintHTTP status code
bodystringRaw response body
jsonanyParsed JSON body (if applicable)
headersobjectResponse headers
urlstringFinal URL (after redirects)
timeintResponse time in milliseconds
methodstringHTTP method used

http.get(url, options?) -> object

Sends an HTTP GET request.

let resp = http.get("https://api.example.com/users")
say resp.status  // 200
say resp.json    // [{id: 1, name: "Alice"}, ...]

http.post(url, options?) -> object

Sends an HTTP POST request.

let resp = http.post("https://api.example.com/users", {
    body: { name: "Alice", email: "alice@example.com" }
})
say resp.status  // 201

http.put(url, options?) -> object

Sends an HTTP PUT request.

let resp = http.put("https://api.example.com/users/1", {
    body: { name: "Alice Updated" }
})

http.delete(url, options?) -> object

Sends an HTTP DELETE request.

let resp = http.delete("https://api.example.com/users/1")
say resp.status  // 204

http.patch(url, options?) -> object

Sends an HTTP PATCH request.

let resp = http.patch("https://api.example.com/users/1", {
    body: { active: false }
})

http.head(url, options?) -> object

Sends an HTTP HEAD request (headers only, no body).

let resp = http.head("https://example.com")
say resp.status

Options Object

FieldTypeDescription
bodyanyRequest body (auto-serialized as JSON)
headersobjectCustom request headers
authstringBearer token (sets Authorization: Bearer <token>)
timeoutintTimeout in seconds (default: 30)
let resp = http.get("https://api.example.com/me", {
    auth: "my-secret-token",
    headers: { "Accept": "application/json" },
    timeout: 10
})

http.download(url, destination?) -> object

Downloads a file from url and saves it to destination. If no destination is provided, the filename is derived from the URL. Returns an object with path, size, and status.

let result = http.download("https://example.com/data.zip", "data.zip")
say result.size  // bytes downloaded

http.crawl(url) -> object

Fetches a web page and extracts structured data. Returns an object with:

FieldTypeDescription
urlstringThe URL crawled
statusintHTTP status code
titlestringPage title
descriptionstringMeta description
linksarrayArray of absolute URLs found in href attributes
textstringVisible text content (first 500 characters)
html_lengthintTotal HTML length in characters
let page = http.crawl("https://example.com")
say page.title
say len(page.links)

http.pretty(response) -> null

Pretty-prints an HTTP response object to stderr with color formatting.

let resp = http.get("https://api.example.com/status")
http.pretty(resp)

Server Decorators

Forge supports declarative HTTP servers using decorators. The @server decorator configures the server, and @get, @post, @put, @delete decorators define route handlers.

@server(port: 3000)

@get("/")
fn index() {
    return { message: "Welcome to Forge!" }
}

@get("/users/:id")
fn get_user(id) {
    return { id: id, name: "User " + id }
}

@post("/users")
fn create_user(body) {
    say "Creating user: " + body.name
    return { ok: true, name: body.name }
}

@delete("/users/:id")
fn delete_user(id) {
    return { deleted: id }
}

Handler Parameters

Handler functions receive arguments based on parameter names:

  • Path parameters (:id, :name) are passed by matching the parameter name.
  • body or data receives the parsed JSON request body.
  • query or qs receives query string parameters as an object.

Server Features

  • Built on axum and tokio for production-grade async performance.
  • CORS is enabled by default (permissive policy).
  • Return values are automatically serialized as JSON responses.
  • WebSocket support via the @ws decorator.

io

Input/output operations and command-line argument handling.

Functions

io.prompt(text?) -> string

Displays text and reads a line of input from stdin. Returns the input with trailing newline removed.

let name = io.prompt("What is your name? ")
say "Hello, " + name

io.print(…args) -> null

Prints arguments to stdout without a trailing newline. Arguments are joined with spaces.

io.print("Loading")
io.print(".")
io.print(".")
io.print(".\n")
// Loading...

io.args() -> array

Returns all command-line arguments as an array of strings, including the program name.

let args = io.args()
say args  // ["forge", "run", "script.fg", "--verbose"]

io.args_parse() -> object

Parses command-line arguments into an object. Flags starting with -- become keys. If a flag is followed by a non-flag value, that value is used; otherwise the flag is set to true.

// forge run script.fg --port 3000 --verbose
let opts = io.args_parse()
say opts["--port"]     // "3000"
say opts["--verbose"]  // true

io.args_get(flag) -> string | bool | null

Returns the value of a specific command-line flag. Returns true if the flag exists but has no value, or null if the flag is not present.

let port = io.args_get("--port")  // "3000" or null
let verbose = io.args_get("--verbose")  // true or null

io.args_has(flag) -> bool

Returns true if the flag is present in the command-line arguments.

if io.args_has("--debug") {
    log.debug("Debug mode enabled")
}

exec

External command execution. The exec module provides a single function, run_command, which is also available as a top-level builtin.

Functions

run_command(command) -> object

Executes an external command and returns a result object. The command string is split by whitespace into the program name and arguments. The command is not executed through a shell, which prevents shell injection.

Returns:

FieldTypeDescription
stdoutstringStandard output (trailing whitespace trimmed)
stderrstringStandard error (trailing whitespace trimmed)
statusintExit code (0 = success)
okbooltrue if exit code is 0
let result = run_command("ls -la")
say result.stdout
say result.ok      // true

let git = run_command("git status")
if git.ok {
    say git.stdout
} else {
    say "Git error: " + git.stderr
}

Notes

  • For shell features (pipes, redirects, globbing), use the sh builtin function instead, which executes through a shell.
  • The command string is split by whitespace, so arguments with spaces are not supported. Use sh for complex commands.

time

Date, time, and timezone operations. Time objects are plain Forge objects with structured fields. Built on the chrono and chrono-tz crates.

Time Object Structure

Most time functions return a time object with these fields:

FieldTypeDescription
isostringISO 8601 / RFC 3339 timestamp
unixintUnix timestamp in seconds
unix_msintUnix timestamp in milliseconds
yearintYear
monthintMonth (1-12)
dayintDay of month (1-31)
hourintHour (0-23)
minuteintMinute (0-59)
secondintSecond (0-59)
weekdaystringFull weekday name (e.g., “Monday”)
weekday_shortstringAbbreviated weekday (e.g., “Mon”)
day_of_yearintDay of the year (1-366)
timezonestringTimezone name

Functions

time.now(timezone?) -> object

Returns the current time as a time object. Defaults to UTC. Pass a timezone string for a specific zone.

let now = time.now()
say now.iso   // "2026-03-02T14:30:00+00:00"
say now.year  // 2026

let tokyo = time.now("Asia/Tokyo")
say tokyo.hour

time.local() -> object

Returns the current time in the system’s local timezone.

let local = time.local()
say local.timezone  // "Local"

time.unix() -> int

Returns the current Unix timestamp in seconds.

let ts = time.unix()
say ts  // e.g. 1772618400

time.today() -> string

Returns today’s date as a "YYYY-MM-DD" string.

say time.today()  // "2026-03-02"

time.date(year, month, day) -> object

Creates a time object for a specific date at midnight UTC.

let christmas = time.date(2026, 12, 25)
say christmas.weekday  // "Friday"

time.parse(input, timezone?) -> object

Parses a date/time string or Unix timestamp into a time object. Supports multiple formats:

  • "2026-03-02T14:30:00Z" (ISO 8601 with timezone)
  • "2026-03-02T14:30:00" (ISO 8601 without timezone)
  • "2026-03-02 14:30:00" (date + time)
  • "2026-03-02" (date only)
  • "03/02/2026" (US format MM/DD/YYYY)
  • "02.03.2026" (European format DD.MM.YYYY)
  • "Mar 02, 2026" (month name)
  • 1772618400 (Unix timestamp as integer)
let t = time.parse("2026-03-02")
say t.weekday  // "Monday"

let t2 = time.parse(1772618400)
say t2.iso

time.format(time_obj, format_str?) -> string

Formats a time object using a strftime-style format string. Defaults to "%Y-%m-%d %H:%M:%S".

let now = time.now()
say time.format(now)                    // "2026-03-02 14:30:00"
say time.format(now, "%B %d, %Y")      // "March 02, 2026"
say time.format(now, "%H:%M")          // "14:30"

time.from_unix(timestamp) -> object

Converts a Unix timestamp (seconds) to a time object.

let t = time.from_unix(0)
say t.iso  // "1970-01-01T00:00:00+00:00"

time.diff(t1, t2) -> object

Returns the difference between two time objects.

FieldTypeDescription
secondsintDifference in seconds (negative if t1 < t2)
minutesfloatDifference in minutes
hoursfloatDifference in hours
daysfloatDifference in days
weeksfloatDifference in weeks
humanstringHuman-readable string (e.g., “2d 3h 15m 0s”)
let a = time.parse("2026-03-01")
let b = time.parse("2026-03-15")
let d = time.diff(b, a)
say d.days   // 14.0
say d.human  // "14d 0h 0m 0s"

time.add(time_obj, duration) -> object

Adds a duration to a time object. Duration can be an object with time unit fields or an integer (seconds).

let now = time.now()
let later = time.add(now, { days: 7, hours: 3 })
let also_later = time.add(now, 3600)  // add 1 hour in seconds

Duration fields: years, months, weeks, days, hours, minutes, seconds, millis.

time.sub(time_obj, duration) -> object

Subtracts a duration from a time object. Same interface as time.add.

let now = time.now()
let yesterday = time.sub(now, { days: 1 })

time.zone(time_obj, timezone) -> object

Converts a time object to a different timezone.

let utc = time.now()
let eastern = time.zone(utc, "America/New_York")
say eastern.hour

time.zones(filter?) -> array

Returns an array of all available timezone names. Optionally filter by substring.

let all = time.zones()
let us = time.zones("America")

time.is_before(t1, t2) -> bool

Returns true if t1 is before t2.

time.is_after(t1, t2) -> bool

Returns true if t1 is after t2.

time.start_of(time_obj, unit) -> object

Returns the start of the given unit: "day", "hour", "minute", "week", "month", "year".

let now = time.now()
let start_of_day = time.start_of(now, "day")
say start_of_day.hour  // 0

time.end_of(time_obj, unit) -> object

Returns the end of the given unit (23:59:59 for days, etc.).

time.sleep(seconds) -> null

Pauses execution for the given number of seconds. Accepts integers or floats.

time.sleep(2)     // sleep 2 seconds
time.sleep(0.5)   // sleep 500ms

time.elapsed() -> int

Returns the current time in milliseconds since the Unix epoch. Useful for measuring performance.

let start = time.elapsed()
// ... do work ...
let duration = time.elapsed() - start
say "Took " + str(duration) + "ms"

time.is_weekend(time_obj?) -> bool

Returns true if the time falls on Saturday or Sunday. Defaults to now.

time.is_weekday(time_obj?) -> bool

Returns true if the time falls on Monday-Friday. Defaults to now.

time.day_of_week(time_obj?) -> string

Returns the full weekday name. Defaults to now.

time.days_in_month(year?, month?) -> int

Returns the number of days in a month. Accepts (year, month) integers or a time object.

time.days_in_month(2024, 2)  // 29 (leap year)
time.days_in_month(2025, 2)  // 28

time.is_leap_year(year?) -> bool

Returns true if the given year is a leap year. Accepts an integer or time object.

time.is_leap_year(2024)  // true
time.is_leap_year(2025)  // false

npc

Fake data generation for testing, prototyping, and seeding databases. “NPC” stands for Non-Player Character – these are the background characters in your application.

Functions

npc.name() -> string

Returns a random full name (first + last).

say npc.name()  // e.g. "Luna Nakamura"

npc.first_name() -> string

Returns a random first name from a diverse, gender-neutral pool.

say npc.first_name()  // e.g. "Phoenix"

npc.last_name() -> string

Returns a random last name from a globally diverse pool.

say npc.last_name()  // e.g. "Patel"

npc.email() -> string

Returns a random email address.

say npc.email()  // e.g. "luna.garcia42@proton.me"

npc.username() -> string

Returns a random username in the format adjective_noun123.

say npc.username()  // e.g. "turbo_wizard847"

npc.phone() -> string

Returns a random US-format phone number.

say npc.phone()  // e.g. "(555) 234-5678"

npc.number(min?, max?) -> int

Returns a random integer. Defaults to range 0-100.

npc.number()        // 0-100
npc.number(1, 6)    // dice roll
npc.number(1000, 9999)  // 4-digit number

npc.pick(array) -> any

Returns a random element from the given array.

let color = npc.pick(["red", "green", "blue"])
say color  // e.g. "green"

npc.bool() -> bool

Returns a random boolean.

say npc.bool()  // true or false

npc.sentence(word_count?) -> string

Returns a random sentence. Default word count is 5-12.

say npc.sentence()    // e.g. "The quick data flows through every node."
say npc.sentence(5)   // exactly 5 words

npc.word() -> string

Returns a single random word.

say npc.word()  // e.g. "algorithms"

npc.id() -> string

Returns a random UUID-like identifier (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).

say npc.id()  // e.g. "a3b2f1c8-d4e5-f6a7-b8c9-d0e1f2a3b4c5"

npc.color() -> string

Returns a random hex color code.

say npc.color()  // e.g. "#3a7fb2"

npc.ip() -> string

Returns a random IPv4 address.

say npc.ip()  // e.g. "192.168.45.12"

npc.url() -> string

Returns a random URL.

say npc.url()  // e.g. "https://techflow.io/dashboard"

npc.company() -> string

Returns a random company name.

say npc.company()  // e.g. "QuantumLeap"

Example: Seeding a Database

db.open(":memory:")
db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")

repeat 100 times {
    db.execute("INSERT INTO users (name, email) VALUES (?, ?)", [
        npc.name(),
        npc.email()
    ])
}

let users = db.query("SELECT * FROM users LIMIT 5")
term.table(users)

Output Functions

Functions for printing to stdout. All output functions accept any number of arguments, which are converted to strings and joined with spaces.

print(…args) -> null

Prints arguments to stdout without a trailing newline.

print("loading")
print("...")
print("\n")
// loading...

println(…args) -> null

Prints arguments to stdout with a trailing newline.

println("Hello, world!")
println("x =", 42)
// Hello, world!
// x = 42

say(…args) -> null

Alias for println. Prints arguments followed by a newline. This is the idiomatic Forge output function.

say "Hello!"
say "The answer is", 42

yell(…args) -> null

Prints arguments in UPPERCASE followed by a newline.

yell "fire detected"
// FIRE DETECTED

whisper(…args) -> null

Prints arguments in lowercase followed by a newline.

whisper "QUIET PLEASE"
// quiet please

Notes

  • print and println are classic-style. say, yell, and whisper are Forge-style.
  • All five functions write to stdout (not stderr). For stderr output, use the log module.
  • Arguments of any type are auto-converted to their string representation.

Type Functions

Functions for type conversion and inspection.

str(value) -> string

Converts any value to its string representation.

str(42)        // "42"
str(3.14)      // "3.14"
str(true)      // "true"
str(null)      // "null"
str([1, 2])    // "[1, 2]"

int(value) -> int

Converts a value to an integer. Accepts integers, floats (truncated), and numeric strings.

int(3.14)     // 3
int("42")     // 42
int(100)      // 100

Errors if the string is not a valid integer.

float(value) -> float

Converts a value to a float. Accepts integers, floats, and numeric strings.

float(42)       // 42.0
float("3.14")   // 3.14
float(1)        // 1.0

Errors if the string is not a valid number.

type(value) -> string

Returns the type name of value as a string.

type(42)          // "Int"
type(3.14)        // "Float"
type("hello")     // "String"
type(true)        // "Bool"
type(null)        // "Null"
type([1, 2])      // "Array"
type({a: 1})      // "Object"
type(fn(x) { x }) // "Function"

typeof(value) -> string

Alias for type. Returns the type name of value.

typeof("hello")  // "String"
typeof(42)       // "Int"

Collection Functions

Functions for working with arrays, objects, and sequences.

len(collection) -> int

Returns the length of a string, array, or object.

len("hello")       // 5
len([1, 2, 3])     // 3
len({a: 1, b: 2})  // 2

push(array, value) -> array

Returns a new array with value appended to the end.

let a = [1, 2, 3]
let b = push(a, 4)
say b  // [1, 2, 3, 4]

Note: push returns a new array. The original array is not modified.

pop(array) -> array

Returns a new array with the last element removed.

let a = [1, 2, 3]
let b = pop(a)
say b  // [1, 2]

keys(object) -> array

Returns an array of the object’s keys as strings.

let obj = { name: "Alice", age: 30 }
say keys(obj)  // ["name", "age"]

values(object) -> array

Returns an array of the object’s values.

let obj = { name: "Alice", age: 30 }
say values(obj)  // ["Alice", 30]

contains(collection, item) -> bool

Checks if a collection contains an item.

  • String, substring: checks if the substring exists in the string.
  • Array, value: checks if the value exists in the array.
  • Object, key: checks if the key exists in the object.
contains("hello world", "world")  // true
contains([1, 2, 3], 2)            // true
contains({a: 1}, "a")             // true
contains({a: 1}, "b")             // false

range(start, end, step?) -> array

Generates an array of integers from start (inclusive) to end (exclusive). Optional step defaults to 1.

range(0, 5)        // [0, 1, 2, 3, 4]
range(1, 10, 2)    // [1, 3, 5, 7, 9]
range(5, 0, -1)    // [5, 4, 3, 2, 1]

enumerate(array) -> array

Returns an array of [index, value] pairs.

let names = ["Alice", "Bob", "Charlie"]
for pair in enumerate(names) {
    say str(pair[0]) + ": " + pair[1]
}
// 0: Alice
// 1: Bob
// 2: Charlie

sum(array) -> int | float

Returns the sum of all numeric elements in an array.

sum([1, 2, 3, 4])    // 10
sum([1.5, 2.5])       // 4.0

min_of(array) -> int | float

Returns the minimum value in an array.

min_of([3, 1, 4, 1, 5])  // 1

max_of(array) -> int | float

Returns the maximum value in an array.

max_of([3, 1, 4, 1, 5])  // 5

unique(array) -> array

Returns a new array with duplicate values removed, preserving order.

unique([1, 2, 2, 3, 1])  // [1, 2, 3]

zip(array_a, array_b) -> array

Combines two arrays into an array of [a, b] pairs. Truncates to the shorter array’s length.

zip([1, 2, 3], ["a", "b", "c"])
// [[1, "a"], [2, "b"], [3, "c"]]

flatten(array) -> array

Flattens nested arrays by one level.

flatten([[1, 2], [3, 4], [5]])  // [1, 2, 3, 4, 5]

group_by(array, fn) -> object

Groups array elements by the string returned by fn. Returns an object where keys are group names and values are arrays.

let people = [
    { name: "Alice", dept: "eng" },
    { name: "Bob", dept: "sales" },
    { name: "Charlie", dept: "eng" }
]
let groups = group_by(people, fn(p) { p.dept })
say keys(groups)  // ["eng", "sales"]
say groups.eng    // [{name: "Alice", dept: "eng"}, {name: "Charlie", dept: "eng"}]

chunk(array, size) -> array

Splits an array into chunks of the given size.

chunk([1, 2, 3, 4, 5], 2)
// [[1, 2], [3, 4], [5]]

slice(array, start, end?) -> array

Returns a sub-array from start (inclusive) to end (exclusive). If end is omitted, slices to the end.

slice([1, 2, 3, 4, 5], 1, 3)  // [2, 3]
slice([1, 2, 3, 4, 5], 2)     // [3, 4, 5]

partition(array, fn) -> array

Splits an array into two arrays: elements where fn returns truthy, and elements where it returns falsy.

let nums = [1, 2, 3, 4, 5, 6]
let result = partition(nums, fn(n) { n % 2 == 0 })
say result[0]  // [2, 4, 6]  (even)
say result[1]  // [1, 3, 5]  (odd)

Functional Operations

map(array, fn) -> array

Applies fn to each element and returns the results.

map([1, 2, 3], fn(x) { x * 2 })  // [2, 4, 6]

filter(array, fn) -> array

Returns elements where fn returns truthy.

filter([1, 2, 3, 4], fn(x) { x > 2 })  // [3, 4]

reduce(array, initial, fn) -> any

Reduces an array to a single value by applying fn(accumulator, element) for each element.

reduce([1, 2, 3, 4], 0, fn(acc, x) { acc + x })  // 10

sort(array, comparator?) -> array

Returns a sorted copy of the array. Without a comparator, sorts numbers numerically and strings alphabetically. The comparator function receives two elements and returns a negative number, zero, or positive number.

sort([3, 1, 4, 1, 5])  // [1, 1, 3, 4, 5]

sort(["banana", "apple", "cherry"])  // ["apple", "banana", "cherry"]

// Custom sort: descending
sort([1, 2, 3], fn(a, b) { b - a })  // [3, 2, 1]

reverse(array) -> array

Returns a reversed copy of the array.

reverse([1, 2, 3])  // [3, 2, 1]

find(array, fn) -> any | null

Returns the first element where fn returns truthy, or null if none match.

find([1, 2, 3, 4], fn(x) { x > 2 })  // 3
find([1, 2], fn(x) { x > 5 })         // null

flat_map(array, fn) -> array

Maps each element with fn and flattens the result by one level.

flat_map([1, 2, 3], fn(x) { [x, x * 10] })
// [1, 10, 2, 20, 3, 30]

any(array, fn) -> bool

Returns true if fn returns truthy for at least one element.

any([1, 2, 3], fn(x) { x > 2 })  // true
any([1, 2, 3], fn(x) { x > 5 })  // false

all(array, fn) -> bool

Returns true if fn returns truthy for every element.

all([2, 4, 6], fn(x) { x % 2 == 0 })  // true
all([2, 3, 6], fn(x) { x % 2 == 0 })  // false

sample(array, n?) -> any | array

Returns a random element (no arguments) or n random elements from the array.

sample([1, 2, 3, 4, 5])     // e.g. 3
sample([1, 2, 3, 4, 5], 2)  // e.g. [4, 1]

shuffle(array) -> array

Returns a randomly shuffled copy of the array.

shuffle([1, 2, 3, 4, 5])  // e.g. [3, 5, 1, 4, 2]

String Functions

Functions for string manipulation. All string functions are non-mutating and return new strings.

split(string, delimiter) -> array

Splits a string by the given delimiter.

split("a,b,c", ",")     // ["a", "b", "c"]
split("hello world", " ") // ["hello", "world"]

join(array, separator?) -> string

Joins array elements into a string with an optional separator. Default separator is empty string.

join(["a", "b", "c"], ", ")  // "a, b, c"
join(["x", "y", "z"])        // "xyz"

replace(string, from, to) -> string

Replaces all occurrences of from with to.

replace("hello world", "world", "Forge")  // "hello Forge"
replace("aabbcc", "bb", "XX")             // "aaXXcc"

starts_with(string, prefix) -> bool

Returns true if string begins with prefix.

starts_with("hello", "hel")   // true
starts_with("hello", "world") // false

ends_with(string, suffix) -> bool

Returns true if string ends with suffix.

ends_with("hello.fg", ".fg")  // true
ends_with("hello.fg", ".rs")  // false

lines(string) -> array

Splits a string into an array of lines.

lines("first\nsecond\nthird")
// ["first", "second", "third"]

substring(string, start, end?) -> string

Extracts a substring from start (inclusive) to end (exclusive). If end is omitted, extracts to the end of the string.

substring("hello world", 0, 5)   // "hello"
substring("hello world", 6)      // "world"

index_of(string, search) -> int

Returns the index of the first occurrence of search in string, or -1 if not found.

index_of("hello world", "world")  // 6
index_of("hello world", "xyz")    // -1

last_index_of(string, search) -> int

Returns the index of the last occurrence of search in string, or -1 if not found.

last_index_of("abcabc", "abc")  // 3
last_index_of("hello", "xyz")   // -1

pad_start(string, length, char?) -> string

Pads the beginning of a string to reach the target length. Default pad character is a space.

pad_start("42", 5, "0")    // "00042"
pad_start("hi", 10)        // "        hi"

pad_end(string, length, char?) -> string

Pads the end of a string to reach the target length. Default pad character is a space.

pad_end("hi", 10, ".")     // "hi........"
pad_end("test", 8)         // "test    "

capitalize(string) -> string

Returns the string with the first character in uppercase.

capitalize("hello")  // "Hello"
capitalize("HELLO")  // "HELLO"

title(string) -> string

Returns the string with the first character of each word capitalized.

title("hello world")        // "Hello World"
title("the quick brown fox") // "The Quick Brown Fox"

repeat_str(string, count) -> string

Returns the string repeated count times.

repeat_str("ha", 3)    // "hahaha"
repeat_str("-", 20)    // "--------------------"

count(string, substring) -> int

Counts the number of non-overlapping occurrences of substring in string.

count("banana", "an")   // 2
count("hello", "l")     // 2

slugify(string) -> string

Converts a string to a URL-friendly slug: lowercase, non-alphanumeric characters replaced with hyphens.

slugify("Hello World!")           // "hello-world"
slugify("The Quick Brown Fox")    // "the-quick-brown-fox"

snake_case(string) -> string

Converts a string to snake_case. Handles camelCase, PascalCase, and spaces.

snake_case("helloWorld")     // "hello_world"
snake_case("MyComponent")    // "my_component"
snake_case("some string")    // "some_string"

camel_case(string) -> string

Converts a string to camelCase.

camel_case("hello_world")    // "helloWorld"
camel_case("some string")    // "someString"

Object Functions

Functions for working with Forge objects (key-value maps).

has_key(object, key) -> bool

Returns true if object contains the specified key.

let user = { name: "Alice", age: 30 }
has_key(user, "name")    // true
has_key(user, "email")   // false

get(object, key, default?) -> any

Retrieves a value from an object by key. Returns default (or null) if the key does not exist. Supports dot-path notation for nested access.

let config = {
    db: {
        host: "localhost",
        port: 5432
    }
}

get(config, "db.host")           // "localhost"
get(config, "db.port")           // 5432
get(config, "db.name", "mydb")   // "mydb" (default)
get(config, "missing")           // null

Also works with arrays by index:

let data = { items: [10, 20, 30] }
get(data, "items.1")  // 20

pick(object, fields) -> object

Returns a new object containing only the specified fields.

let user = { name: "Alice", age: 30, email: "alice@example.com" }
pick(user, ["name", "email"])
// { name: "Alice", email: "alice@example.com" }

omit(object, fields) -> object

Returns a new object with the specified fields removed.

let user = { name: "Alice", age: 30, password: "secret" }
omit(user, ["password"])
// { name: "Alice", age: 30 }

merge(…objects) -> object

Merges multiple objects into one. Later objects override earlier keys.

let defaults = { color: "blue", size: "medium" }
let overrides = { size: "large", weight: 10 }
merge(defaults, overrides)
// { color: "blue", size: "large", weight: 10 }

entries(object) -> array

Returns an array of [key, value] pairs.

let obj = { a: 1, b: 2 }
entries(obj)
// [["a", 1], ["b", 2]]

from_entries(array) -> object

Converts an array of [key, value] pairs into an object. The inverse of entries.

from_entries([["name", "Alice"], ["age", 30]])
// { name: "Alice", age: 30 }

diff(object_a, object_b) -> object

Returns an object describing the differences between two objects. Each key that differs contains an object with a and b values.

let old = { name: "Alice", age: 30, city: "NYC" }
let new_val = { name: "Alice", age: 31, email: "a@b.com" }
diff(old, new_val)
// {
//   age: { a: 30, b: 31 },
//   city: { a: "NYC", b: null },
//   email: { a: null, b: "a@b.com" }
// }

Shell Functions

Functions for executing shell commands and interacting with the operating system. Unlike run_command, these functions execute through a system shell (/bin/sh on Unix, cmd on Windows), so pipes, redirects, and globbing work.

sh(command) -> string

Executes a shell command and returns stdout as a string. Errors if the command fails.

let files = sh("ls -la")
say files

let count = sh("wc -l < data.txt")
say count

shell(command) -> object

Executes a shell command and returns a detailed result object with stdout, stderr, status, and ok fields.

let result = shell("git status")
if result.ok {
    say result.stdout
} else {
    say "Error: " + result.stderr
}

sh_lines(command) -> array

Executes a shell command and returns stdout split into an array of lines.

let files = sh_lines("ls *.fg")
for file in files {
    say "Found: " + file
}

sh_json(command) -> any

Executes a shell command and parses stdout as JSON.

let config = sh_json("cat package.json")
say config.name
say config.version

sh_ok(command) -> bool

Executes a shell command and returns true if the exit code is 0.

if sh_ok("which python3") {
    say "Python 3 is installed"
}

which(program) -> string | null

Returns the full path to program, or null if not found. Equivalent to the Unix which command.

which("node")    // "/usr/local/bin/node"
which("foobar")  // null

cwd() -> string

Returns the current working directory as a string.

say cwd()  // "/home/alice/project"

cd(path) -> null

Changes the current working directory.

cd("/tmp")
say cwd()  // "/tmp"

pipe_to(command, input) -> string

Pipes input as stdin to the given command and returns stdout.

let sorted = pipe_to("sort", "banana\napple\ncherry")
say sorted
// apple
// banana
// cherry

Assertion Functions

Functions for testing and validation. Assertion failures produce runtime errors with descriptive messages.

assert(condition, message?) -> null

Asserts that condition is truthy. Throws a runtime error if false.

assert(1 + 1 == 2)
assert(len("hello") > 0, "string should not be empty")

assert_eq(actual, expected, message?) -> null

Asserts that actual equals expected. Shows both values on failure.

assert_eq(1 + 1, 2)
assert_eq(sort([3, 1, 2]), [1, 2, 3])
assert_eq(user.name, "Alice", "user name mismatch")

assert_ne(actual, expected, message?) -> null

Asserts that actual does not equal expected.

assert_ne(1, 2)
assert_ne(user.role, "admin", "user should not be admin")

assert_throws(fn, message?) -> null

Asserts that calling fn produces a runtime error. Useful for testing error handling.

assert_throws(fn() { int("not a number") })
assert_throws(fn() { bruh "expected crash" })

satisfies(value, interface) -> bool

Checks whether value structurally satisfies an interface (Go-style structural typing). Returns true if the value has all methods specified by the interface, either through the environment or through give/impl blocks.

power Printable {
    fn to_string() -> string
}

thing User {
    name: string
}

give User {
    fn to_string() {
        return self.name
    }
}

let u = User { name: "Alice" }
assert(satisfies(u, Printable))

See the Type System chapter for more details on interface satisfaction.

GenZ Debug Kit

A set of debugging and assertion functions with personality. These are fully functional tools with expressive error messages – not just jokes.

sus(value) -> value

Inspects a value and prints its type and content to stderr, then returns the value unchanged. Works like Rust’s dbg! macro – you can wrap any expression without changing program behavior.

let x = sus(42)
// stderr: 🔍 SUS CHECK: 42 (Int)
// x is still 42

let result = sus(http.get("https://api.example.com"))
// Prints the response object, then returns it

bruh(message?) -> never

Panics with a runtime error. Equivalent to panic! in Rust. Default message: “something ain’t right”.

bruh "database connection lost"
// Error: BRUH: database connection lost

bruh
// Error: BRUH: something ain't right

bet(condition, message?) -> bool

Asserts that condition is truthy. Returns true on success, errors on failure. Equivalent to assert.

bet(user.age >= 18, "user must be an adult")
// On failure: Error: LOST THE BET: user must be an adult

bet(1 + 1 == 2)  // passes, returns true

no_cap(a, b) -> bool

Asserts that a equals b. Returns true on success, errors on failure. Equivalent to assert_eq.

no_cap(1 + 1, 2)  // passes
no_cap("hello", "world")
// Error: CAP DETECTED: hello ≠ world

ick(condition, message?) -> bool

Asserts that condition is false. Returns true when the condition is false, errors when true. The inverse of bet.

ick(user.banned, "user should not be banned")
// On failure: Error: ICK: user should not be banned

ick(false)  // passes, returns true

Execution Helpers

Higher-order functions for profiling, error handling, and benchmarking.

cook(fn) -> any

Executes fn, measures its execution time, and prints a performance report to stderr. Returns the function’s return value.

let result = cook(fn() {
    let total = 0
    for i in range(0, 1000000) {
        total = total + i
    }
    return total
})
// stderr: 👨‍🍳 COOKED: done in 42.15ms — it's giving adequate
// result contains the computed total

Performance messages vary by duration:

  • Under 1ms: “speed demon fr”
  • 1-100ms: “no cap that was fast”
  • 100ms-1s: “it’s giving adequate”
  • Over 1s: “bruh that took a minute”

yolo(fn) -> any | None

Executes fn and swallows all errors. Returns the function’s result on success, or None on failure. Useful for non-critical operations where errors can be safely ignored.

let data = yolo(fn() {
    return fs.read("maybe-missing.txt")
})
// data is the file contents or None

if is_none(data) {
    say "File not found, using defaults"
}

ghost(fn) -> any

Executes fn silently. The function runs normally and its return value is passed through, but intended for cases where you want to suppress side effects.

let result = ghost(fn() {
    return compute_something()
})

slay(fn, iterations?) -> object

Benchmarks fn by running it iterations times (default: 100). Prints a summary to stderr and returns a statistics object.

Returns:

FieldTypeDescription
avg_msfloatAverage time per iteration in milliseconds
min_msfloatMinimum time in milliseconds
max_msfloatMaximum time in milliseconds
p99_msfloat99th percentile time in milliseconds
runsintNumber of iterations
resultanyReturn value of the last iteration
let stats = slay(fn() {
    return math.pow(2, 20)
}, 1000)

// stderr: 💅 SLAYED: 1000x runs — avg 0.003ms, min 0.001ms, max 0.012ms, p99 0.008ms

say stats.avg_ms   // 0.003
say stats.runs     // 1000
say stats.result   // 1048576

Dual Syntax Philosophy

Principle

Every construct in Forge has two spellings: a classic form familiar to programmers coming from C, Rust, or JavaScript, and a natural form that reads closer to English. Both forms compile to the exact same AST and execute identically. There is no performance difference, no feature difference, and no hidden cost.

Why Dual Syntax?

Programming languages force a false choice: be accessible or be powerful. Forge rejects this tradeoff.

  • Classic syntax serves experienced developers who think in fn, let, and else. It is terse and precise.
  • Natural syntax serves learners, scripters, and domain experts who prefer define, set, and otherwise. It reduces the cognitive barrier to writing code.

Neither form is “training wheels.” Both are first-class citizens of the language.

Rules

  1. Both forms are always available. No mode switches, no compiler flags, no feature gates.
  2. They can be mixed freely. You can use let on one line and set on the next. A file can use fn for one function and define for another.
  3. The AST is identical. The parser recognizes both spellings and produces the same node. let x = 5 and set x to 5 produce an identical LetDecl AST node.
  4. Error messages normalize. Runtime errors use the classic form regardless of which syntax the programmer used.
  5. Formatting preserves choice. forge fmt does not convert between forms. Your stylistic choice is respected.

Example: Mixed Syntax

// Classic variable, natural function
let name = "Alice"
define greet(person) {
    say "Hello, " + person
}

// Natural variable, classic function
set age to 30
fn is_adult(a) {
    return a >= 18
}

// Both work together seamlessly
if is_adult(age) {
    greet(name)
} otherwise {
    say "Too young"
}

Design Guideline

When in doubt, use whichever form your team agrees on. For library code shared publicly, classic syntax is conventional. For scripts, tutorials, and learning materials, natural syntax often reads better.

Syntax Mapping

Complete mapping between classic and natural forms. Both compile to identical AST nodes.

Variables

ClassicNaturalDescription
let x = 5set x to 5Immutable binding
let mut x = 0set mut x to 0Mutable binding
x = 10change x to 10Reassignment
let {a, b} = objunpack {a, b} from objDestructuring
// Classic
let name = "Alice"
let mut count = 0
count = count + 1

// Natural
set name to "Alice"
set mut count to 0
change count to count + 1

Functions

ClassicNaturalDescription
fn add(a, b) { }define add(a, b) { }Function definition
async fn fetch() { }forge fetch() { }Async function
return valuereturn valueReturn (same in both)
// Classic
fn greet(name) {
    return "Hello, " + name
}

// Natural
define greet(name) {
    return "Hello, " + name
}

Control Flow

ClassicNaturalDescription
else { }otherwise { }Else branch
else { }nah { }Else branch (casual)
else ifotherwise ifElse-if branch
// Classic
if x > 0 {
    say "positive"
} else if x == 0 {
    say "zero"
} else {
    say "negative"
}

// Natural
if x > 0 {
    say "positive"
} otherwise if x == 0 {
    say "zero"
} nah {
    say "negative"
}

Output

ClassicNaturalDescription
println("text")say "text"Print with newline
print("text")print("text")Print without newline
yell "text"Print uppercased
whisper "text"Print lowercased

Types and Structures

ClassicNaturalDescription
struct User { }thing User { }Struct definition
impl User { }give User { }Method implementation
interface Printable { }power Printable { }Interface definition
enum Color { }craft Color { }Enum definition
// Classic
struct User {
    name: string,
    age: int
}

impl User {
    fn greet(self) {
        say "Hi, I'm " + self.name
    }
}

// Natural
thing User {
    name: string,
    age: int
}

give User {
    fn greet(self) {
        say "Hi, I'm " + self.name
    }
}

Async / Concurrency

ClassicNaturalDescription
async fn x() { }forge x() { }Async function
await exprhold exprAwait an async value
yield valueemit valueYield from a generator
fetch("url")grab resp from "url"HTTP fetch
// Classic
async fn get_data() {
    let resp = await fetch("https://api.example.com/data")
    return resp
}

// Natural
forge get_data() {
    let resp = hold grab data from "https://api.example.com/data"
    return resp
}

Pattern Matching

ClassicNatural
match value { }match value { }
when value { }when value { }

Both match and when are available; when supports guard-style syntax unique to Forge (see Innovation Keywords).

Modules

ClassicNaturalDescription
has InterfaceNamehas InterfaceNameInterface conformance assertion

The has keyword asserts that a type satisfies an interface at the point of declaration.

Innovation Keywords

Keywords unique to Forge that have no direct equivalent in other mainstream languages. These are not aliases – they introduce genuinely new constructs.

when Guards

Pattern matching with comparison guards. Unlike match, when tests a single value against comparison operators.

let score = 85
let grade = when score {
    >= 90 -> "A",
    >= 80 -> "B",
    >= 70 -> "C",
    >= 60 -> "D",
    else -> "F"
}
say grade  // "B"

The else arm is required and handles any unmatched case.

must

Unwraps a Result or crashes with a clear error message. Used when failure is unrecoverable.

let data = must fs.read("config.json")
// If the file doesn't exist, the program crashes with a descriptive error

safe

Null-safe execution block. If any expression inside safe would error, the block evaluates to null instead of crashing. Statement-only (cannot be used as an expression).

safe {
    let data = fs.read("maybe-missing.txt")
    say data
}
// If file is missing, execution continues silently

check … is not empty

Declarative validation. Checks a condition and produces a validation error if it fails.

check name is not empty
check age >= 0
check email contains "@"

retry N times

Automatically retries a block up to N times on failure.

retry 3 times {
    let resp = http.get("https://flaky-api.example.com/data")
    say resp.json
}

If all retries fail, the error from the last attempt is raised.

timeout N seconds

Limits execution time for a block. If the block does not complete within the time limit, it is interrupted.

timeout 5 seconds {
    let result = http.get("https://slow-api.example.com")
    say result.json
}

Note: This feature is experimental and may not interrupt all operations cleanly.

schedule every N units

Runs a block repeatedly on a schedule (cron-like).

schedule every 5 minutes {
    let status = http.get("https://api.example.com/health")
    if status.status != 200 {
        log.error("Health check failed!")
    }
}

Supported units: seconds, minutes, hours.

watch “path”

Monitors a file or directory for changes and executes the block when changes are detected.

watch "src/" {
    say "Files changed, rebuilding..."
    sh("cargo build")
}

ask “prompt”

Sends a prompt to an AI/LLM and returns the response. Requires AI configuration.

let answer = ask "What is the capital of France?"
say answer  // "Paris"

download “url” to “file”

Downloads a file from a URL and saves it to disk. Syntax sugar for http.download.

download "https://example.com/data.csv" to "data.csv"

crawl “url”

Fetches and parses a web page, returning structured data. Syntax sugar for http.crawl.

let page = crawl "https://example.com"
say page.title
say page.links

repeat N times

Executes a block exactly N times. A counted loop without a loop variable.

repeat 5 times {
    say "Hello!"
}

wait N units

Pauses execution for the specified duration.

wait 2 seconds
wait 500 milliseconds

Supported units: seconds, milliseconds, minutes.

grab … from “url”

Natural syntax for HTTP fetch. Assigns the response to a variable.

grab data from "https://api.example.com/users"
say data

emit value

Yields a value from a generator function. Natural equivalent of yield.

fn fibonacci() {
    let a = 0
    let b = 1
    loop {
        emit a
        let temp = a + b
        a = b
        b = temp
    }
}

hold expr

Awaits an async expression. Natural equivalent of await.

forge fetch_data() {
    let resp = hold http.get("https://api.example.com")
    return resp.json
}

Execution Model

Forge provides three execution tiers, selectable at the command line. All tiers accept the same source files; they differ in feature coverage, performance, and implementation strategy.

Three Tiers

TierFlagImplementationPerformanceFeature Coverage
Interpreter(default)Tree-walkingBaselineFull (100%)
Bytecode VM--vmRegister-based VM~10x faster than interpreterPartial (~60%)
JIT Compiler--jitCranelift native code~50-100x faster than interpreterMinimal (~30%)
forge run program.fg          # Interpreter (default)
forge run program.fg --vm     # Bytecode VM
forge run program.fg --jit    # JIT compiler

When to Use Each Tier

Interpreter (Default)

Use the interpreter for:

  • All general-purpose development
  • HTTP servers (@server, @get, @post)
  • Database access (db, pg)
  • AI integration (ask)
  • File system, crypto, terminal UI
  • Any code using the full standard library

The interpreter supports every feature of the language. It is the reference implementation.

Bytecode VM (--vm)

Use the VM for:

  • Compute-intensive loops and numerical work
  • Programs that primarily use math, fs, io, and basic control flow
  • Benchmarking against the interpreter

The VM compiles Forge source to a register-based bytecode and executes it in a virtual machine with mark-sweep garbage collection. It does not support HTTP servers, database connections, or several stdlib modules.

JIT Compiler (--jit)

Use the JIT for:

  • Maximum performance on hot numerical code
  • Benchmarking (e.g., fib(30) runs 11x faster than Python)
  • Functions that are purely computational

The JIT compiles hot functions to native machine code via Cranelift. It supports the smallest subset of the language – primarily arithmetic, function calls, and basic control flow.

Trade-off Summary

Feature Coverage:  Interpreter > VM > JIT
Performance:       JIT > VM > Interpreter
Startup Time:      Interpreter < VM < JIT

The interpreter is always the safe default. Switch to --vm or --jit only when you need the performance and have verified your program works on that tier.

Interpreter

The Forge interpreter is a tree-walking interpreter implemented in Rust. It traverses the AST directly, evaluating each node as it encounters it. This is the default and most complete execution engine.

Architecture

Source (.fg) -> Lexer -> Parser -> AST -> Interpreter -> Result
                                            |
                                     Environment (scopes)
                                            |
                                     Runtime Bridge
                                  (axum, reqwest, tokio, rusqlite)

The interpreter lives in src/interpreter/mod.rs (~8,100 lines) and is the largest single file in the codebase.

Key Components

Environment

The interpreter maintains a stack of scopes. Each scope is an IndexMap<String, Value> that maps names to values. Variable resolution walks the scope stack from innermost to outermost.

  • Global scope: Pre-populated with all 16 stdlib modules and all built-in functions.
  • Function scope: Created on each function call, closed over by lambdas.
  • Block scope: Created for if, for, while, and other block statements.

Value Type

The Value enum represents all runtime values:

  • Int(i64) – 64-bit integer
  • Float(f64) – 64-bit float
  • Bool(bool) – boolean
  • String(String) – heap-allocated string
  • Array(Vec<Value>) – dynamic array
  • Object(IndexMap<String, Value>) – ordered key-value map
  • Null – null value
  • Function { params, body, closure } – named function with captured environment
  • Lambda { params, body, closure } – anonymous function
  • BuiltIn(String) – reference to a built-in function by name
  • ResultOk(Box<Value>) / ResultErr(Box<Value>) – Result type
  • Some(Box<Value>) / None – Option type
  • Channel(Arc<ChannelInner>) – concurrency channel
  • TaskHandle(Arc<TaskInner>) – async task handle

Dispatch

Built-in function dispatch is a single large match statement in call_builtin. When a BuiltIn("name") value is called, the interpreter matches on the name string and executes the corresponding Rust code.

Stdlib module functions (e.g., math.sqrt) are dispatched through the module’s call function. The interpreter detects dot-access on a module object and routes the call to the appropriate module.

Features Unique to the Interpreter

The following features are only available in the interpreter tier:

  • HTTP server (@server, @get, @post, @delete, @ws)
  • Database access (db.open, db.query, pg.connect)
  • AI integration (ask)
  • Web scraping (crawl)
  • File download (download ... to)
  • Terminal UI widgets (term.table, term.menu, term.confirm)
  • GenZ debug kit (sus, bruh, bet, no_cap, ick)
  • Execution helpers (cook, yolo, ghost, slay)
  • Concurrency (channel, send, receive, spawn)

Performance Characteristics

The tree-walking approach means the interpreter re-traverses the AST on every loop iteration and function call. This makes it approximately 20x slower than Python for deep recursion benchmarks like fib(35).

For most real-world scripts (file processing, HTTP handlers, database queries), interpreter overhead is negligible compared to I/O latency. The interpreter is the recommended tier for all general-purpose work.

Bytecode VM

The Forge bytecode VM is a register-based virtual machine that compiles Forge source to 32-bit instructions and executes them in a loop. It provides significantly better performance than the tree-walking interpreter at the cost of reduced feature coverage.

Invocation

forge run program.fg --vm

Architecture

Source -> Lexer -> Parser -> AST -> Compiler -> Bytecode Chunks -> Machine -> Result
                                                                     |
                                                              Mark-Sweep GC
                                                                     |
                                                              Green Threads

Key source files:

  • src/vm/compiler.rs (~927 lines) – AST to bytecode compilation
  • src/vm/machine.rs (~2,483 lines) – bytecode execution engine
  • src/vm/bytecode.rs – instruction set definition
  • src/vm/gc.rs – mark-sweep garbage collector
  • src/vm/frame.rs – call frame management
  • src/vm/value.rs – VM-specific value type
  • src/vm/green.rs – green thread scheduler

Instruction Encoding

All instructions are 32 bits wide. Three encoding formats:

ABC Format: [op:8][a:8][b:8][c:8]

Used for register-to-register operations. a is typically the destination register; b and c are source registers.

ABx Format: [op:8][a:8][bx:16]

Used for instructions with a larger operand, such as constant loading. bx is an unsigned 16-bit index.

AsBx Format: [op:8][a:8][sbx:16]

Used for jump instructions. sbx is a signed 16-bit offset stored as unsigned (with bias).

Important: The VM pre-increments IP before applying jump offsets. The JIT target address is ip + 1 + sbx, not ip + sbx.

Register Machine

The VM uses a register-based architecture rather than a stack-based one. Each call frame has its own register window. Registers are addressed by 8-bit indices, allowing up to 256 registers per frame.

Benefits:

  • Fewer instructions than a stack VM (no push/pop for every operand)
  • Better cache locality for register access
  • Natural fit for the JIT tier

Constant Pool

Each compiled function (called a “Chunk”) has a constant pool for literals, strings, and function prototypes. Constants are deduplicated via identical() comparison to avoid wasting pool slots.

Garbage Collection

The VM uses a mark-sweep garbage collector. Heap-allocated objects (strings, arrays, objects, closures) are tracked by the GC. Collection is triggered when the allocation count exceeds a threshold.

The mark phase walks from GC roots (registers, global environment, call stack). The sweep phase frees unreachable objects.

Green Threads

The VM includes a cooperative green thread scheduler (src/vm/green.rs). Green threads are multiplexed over a single OS thread with explicit yield points.

Supported Features

The VM supports core language features:

  • Variables, functions, closures
  • Control flow (if/else, for, while, match)
  • Arrays and objects
  • Arithmetic and comparison operators
  • String operations
  • math, fs, io, npc modules
  • Basic built-in functions

Unsupported Features

The following require the interpreter:

  • HTTP server and client
  • Database connections
  • AI integration
  • Terminal UI widgets
  • Most execution helpers

JIT Compiler

The Forge JIT compiler translates hot bytecode functions into native machine code using the Cranelift code generator. It provides the highest performance tier, achieving approximately 11x faster execution than Python on recursive benchmarks like fib(30).

Invocation

forge run program.fg --jit

Architecture

Source -> Lexer -> Parser -> AST -> Compiler -> Bytecode -> JIT -> Native Code
                                                             |
                                                      Cranelift IR
                                                             |
                                                    Machine Code (x86_64/AArch64)

Key source files:

  • src/vm/jit/ir_builder.rs (~276 lines) – Bytecode to Cranelift IR translation
  • src/vm/jit/jit_module.rs (~47 lines) – JIT module management

How It Works

  1. The program is first compiled to bytecode (same as the --vm path).
  2. Functions selected for JIT compilation are translated from bytecode into Cranelift’s intermediate representation (IR).
  3. Cranelift compiles the IR to native machine code for the host architecture.
  4. The native code is loaded into memory and called directly, bypassing the bytecode interpreter.

Cranelift IR Translation

The IR builder walks the bytecode instruction stream and emits Cranelift IR operations:

  • Arithmetic bytecodes (ADD, SUB, MUL, DIV) map to Cranelift iadd, isub, imul, sdiv.
  • Comparison bytecodes map to Cranelift icmp with the appropriate condition.
  • Jump bytecodes map to Cranelift branch and block terminators.
  • Function calls generate Cranelift call instructions.

Jump Offset Encoding

The VM pre-increments the instruction pointer (IP) before applying jump offsets. When translating jumps to Cranelift blocks, the JIT target is calculated as:

target = ip + 1 + sbx

This is a critical detail. Using ip + sbx produces incorrect branch targets.

Performance

On fib(30):

EngineTimeRelative
Python 3~330ms1x
Forge interpreter~6,600ms0.05x
Forge VM~660ms0.5x
Forge JIT~30ms11x

The JIT excels at tight numerical loops and recursive functions where the overhead of interpretation dominates.

Supported Features

The JIT supports the most restricted subset:

  • Integer and float arithmetic
  • Function calls and recursion
  • Basic control flow (if/else, loops)
  • Local variables
  • Comparisons and boolean logic

Limitations

  • No string operations
  • No object or array construction
  • No standard library access
  • No closures or higher-order functions
  • No error handling (try/catch, Result)
  • Compilation overhead makes it unsuitable for short-running programs
  • Only beneficial for compute-heavy inner loops

Platform Support

Cranelift supports:

  • x86_64 (macOS, Linux, Windows)
  • AArch64 / ARM64 (macOS Apple Silicon, Linux)

HTTP Server

Forge includes a built-in HTTP server powered by axum and tokio. Servers are defined declaratively using decorators and launched automatically when the interpreter detects a @server directive.

Architecture

Forge Source
    |
    v
Parser extracts decorators (@server, @get, @post, ...)
    |
    v
runtime/server.rs builds axum Router
    |
    v
axum + tokio serve requests
    |
    v
Each request locks the Interpreter mutex,
calls the handler function, serializes the return value as JSON

The server implementation lives in src/runtime/server.rs (~354 lines).

Server Configuration

The @server decorator configures the server:

@server(port: 3000, host: "0.0.0.0")
ParameterTypeDefaultDescription
portint8080Listen port
hoststring“127.0.0.1”Bind address

Route Decorators

@get(path?)

Registers a function as a GET handler.

@get("/users")
fn list_users() {
    return [{ name: "Alice" }, { name: "Bob" }]
}

@post(path?)

Registers a function as a POST handler.

@post("/users")
fn create_user(body) {
    say "Creating: " + body.name
    return { ok: true }
}

@put(path?)

Registers a function as a PUT handler.

@delete(path?)

Registers a function as a DELETE handler.

@ws(path?)

Registers a function as a WebSocket handler. The function receives each incoming message as a string and returns a response string.

@ws("/chat")
fn handle_message(msg) {
    return "Echo: " + msg
}

Path Parameters

Path parameters use colon syntax (:param). They are automatically mapped to function parameters by name.

@get("/users/:id")
fn get_user(id) {
    return { id: id, name: "User " + id }
}
// GET /users/42 -> { id: "42", name: "User 42" }

The Forge path syntax (:id) is internally converted to axum’s brace syntax ({id}).

Handler Parameters

Handler functions receive arguments based on their parameter names:

Parameter NameSource
Name matching a path paramURL path parameter
body or dataParsed JSON request body
query or qsQuery string as an object
Name matching a query paramIndividual query parameter
Othernull
@post("/search")
fn search(body, query) {
    say "Search body: " + str(body)
    say "Query params: " + str(query)
    return { results: [] }
}

JSON Serialization

Return values from handlers are automatically serialized to JSON:

Forge TypeJSON
intnumber
floatnumber
boolboolean
stringstring
nullnull
arrayarray
objectobject
ResultOk(v){"Ok": v}
ResultErr(v){"Err": v}
Other"<TypeName>"

Error Handling

If a handler function throws a runtime error, the server returns HTTP 500 with:

{ "error": "error message here" }

CORS

CORS is enabled by default with a permissive policy (all origins, all methods, all headers). This is suitable for development; production deployments should add appropriate restrictions at the reverse proxy level.

Concurrency Model

The interpreter is wrapped in an Arc<Mutex<Interpreter>>. Each incoming request acquires the mutex lock, calls the handler, and releases it. This means handlers execute serially – only one request is processed at a time.

For high-throughput scenarios, consider running multiple Forge server instances behind a load balancer.

Memory Model

Forge uses different memory management strategies depending on the execution tier.

Interpreter Memory

The interpreter uses Rust’s standard memory management with no garbage collector.

Value Representation

All Forge values are represented by the Value enum in Rust. Heap-allocated variants:

ValueHeap Allocation
String(String)Rust String (heap-allocated, growable)
Array(Vec<Value>)Vec on the heap
Object(IndexMap<String, Value>)IndexMap on the heap
Function { closure, ... }Captured environment on the heap
Lambda { closure, ... }Captured environment on the heap
Channel(Arc<ChannelInner>)Reference-counted channel
TaskHandle(Arc<TaskInner>)Reference-counted task

Primitive types (Int, Float, Bool, Null) are stored inline without heap allocation.

Ownership and Cloning

The interpreter clones values when:

  • Passing arguments to functions
  • Returning values from functions
  • Assigning variables
  • Indexing into arrays or objects

This is a simple, correct approach that avoids shared mutable state. The trade-off is increased memory allocation for large data structures.

Reference Counting

Channels and task handles use Arc (atomic reference counting) for shared ownership across threads. When the last reference is dropped, the resource is freed.

No Manual Memory Management

Forge programs never explicitly allocate or free memory. There is no malloc, free, new, or delete. Memory is managed entirely by the Rust runtime.

VM Memory

The bytecode VM uses a mark-sweep garbage collector for heap-allocated objects.

VM Value Representation

The VM has its own Value enum (in src/vm/value.rs) optimized for the register-based architecture:

ValueRepresentation
Int(i64)Inline 64-bit integer
Float(f64)Inline 64-bit float
Bool(bool)Inline boolean
NullInline null marker
Obj(usize)Index into the GC heap

Heap-allocated objects are stored in the GC’s object table and referenced by index.

Object Kinds

ObjKind::String(String)
ObjKind::Array(Vec<Value>)
ObjKind::Object(IndexMap<String, Value>)
ObjKind::Closure { ... }

GC Algorithm

The mark-sweep collector works in two phases:

  1. Mark: Starting from roots (registers, global environment, call stack frames), traverse all reachable objects and set their mark bit.
  2. Sweep: Walk the object table. Free any object without a mark bit. Clear all mark bits for the next cycle.

Collection is triggered when the number of allocated objects exceeds a dynamic threshold that grows as the program allocates more objects.

GC Roots

The following locations are scanned as roots:

  • All registers in the current and parent call frames
  • The global environment
  • Constant pools of active chunks
  • Green thread stacks

JIT Memory

The JIT compiler allocates executable memory pages for generated native code via Cranelift’s JITModule. This memory is mapped with execute permissions and is freed when the JIT module is dropped.

JIT-compiled functions operate on machine registers and stack-allocated values. There is no GC interaction – the JIT tier does not support heap-allocated objects.

Grammar

Simplified EBNF grammar for the Forge language. This is a reference guide, not a formal specification. Optional elements are marked with ?, repetition with *, and alternation with |.

Program

program        = statement* EOF ;

Statements

statement      = let_stmt
               | assign_stmt
               | fn_def
               | struct_def
               | interface_def
               | impl_block
               | type_def
               | if_stmt
               | match_stmt
               | when_stmt
               | for_stmt
               | while_stmt
               | loop_stmt
               | return_stmt
               | break_stmt
               | continue_stmt
               | try_catch
               | import_stmt
               | spawn_stmt
               | destructure_stmt
               | check_stmt
               | safe_block
               | timeout_block
               | retry_block
               | schedule_block
               | watch_block
               | prompt_def
               | decorator_stmt
               | yield_stmt
               | expression_stmt ;

Variable Declarations

let_stmt       = ( "let" | "set" ) "mut"? IDENT ( ":" type_ann )? ( "=" | "to" ) expr NEWLINE ;
assign_stmt    = ( IDENT | field_access | index_expr ) ( "=" | "+=" | "-=" | "*=" | "/=" ) expr NEWLINE
               | "change" IDENT "to" expr NEWLINE ;
destructure_stmt = ( "let" | "unpack" ) destruct_pattern ( "=" | "from" ) expr NEWLINE ;
destruct_pattern = "{" IDENT ( "," IDENT )* "}"
                 | "[" IDENT ( "," IDENT )* ( "," "..." IDENT )? "]" ;

Function Definitions

fn_def         = decorator* ( "fn" | "define" | "async" "fn" | "forge" ) IDENT "(" param_list? ")" ( "->" type_ann )? block ;
param_list     = param ( "," param )* ;
param          = IDENT ( ":" type_ann )? ( "=" expr )? ;
block          = "{" statement* "}" ;

Struct / Thing Definitions

struct_def     = ( "struct" | "thing" ) IDENT "{" field_def* "}" ;
field_def      = IDENT ":" type_ann ( "=" expr )? NEWLINE ;

Interface / Power Definitions

interface_def  = ( "interface" | "power" ) IDENT "{" method_sig* "}" ;
method_sig     = IDENT "(" param_list? ")" ( "->" type_ann )? NEWLINE ;

Impl / Give Blocks

impl_block     = "impl" IDENT "{" fn_def* "}"
               | "give" IDENT "{" fn_def* "}"
               | "give" IDENT "the" "power" IDENT "{" fn_def* "}" ;

Type Definitions (ADTs)

type_def       = "type" IDENT "=" variant ( "|" variant )* ;
variant        = IDENT ( "(" type_ann ( "," type_ann )* ")" )? ;

Control Flow

if_stmt        = "if" expr block ( ( "else" | "otherwise" | "nah" ) ( if_stmt | block ) )? ;
match_stmt     = "match" expr "{" match_arm* "}" ;
match_arm      = pattern "->" ( expr | block ) ","? ;
pattern        = "_"
               | literal
               | IDENT
               | IDENT "(" pattern ( "," pattern )* ")" ;

when_stmt      = "when" expr "{" when_arm* "}" ;
when_arm       = ( comparison_op expr | "else" ) "->" expr ","? ;

for_stmt       = "for" IDENT ( "," IDENT )? "in" expr block
               | "for" "each" IDENT "in" expr block ;
while_stmt     = "while" expr block ;
loop_stmt      = "loop" block ;
return_stmt    = "return" expr? NEWLINE ;
break_stmt     = "break" NEWLINE ;
continue_stmt  = "continue" NEWLINE ;
yield_stmt     = ( "yield" | "emit" ) expr NEWLINE ;

Error Handling

try_catch      = "try" block "catch" IDENT block ;

Import

import_stmt    = "import" STRING
               | "from" STRING "import" IDENT ( "," IDENT )* ;

Spawn

spawn_stmt     = "spawn" block ;

Innovation Statements

check_stmt     = "check" expr ( "is" "not"? "empty" | "contains" expr | "between" expr "and" expr ) ;
safe_block     = "safe" block ;
timeout_block  = "timeout" expr "seconds" block ;
retry_block    = "retry" expr "times" block ;
schedule_block = "schedule" "every" expr ( "seconds" | "minutes" ) block ;
watch_block    = "watch" expr block ;

Decorators

decorator      = "@" IDENT ( "(" decorator_args? ")" )? ;
decorator_args = decorator_arg ( "," decorator_arg )* ;
decorator_arg  = IDENT ":" expr
               | expr ;

Prompt Definitions

prompt_def     = "prompt" IDENT "(" param_list? ")" "{" prompt_body "}" ;
prompt_body    = ( "system" ":" STRING )? "user" ":" STRING ( "returns" ":" STRING )? ;

Expressions

expr           = or_expr ;
or_expr        = and_expr ( "||" and_expr )* ;
and_expr       = equality ( "&&" equality )* ;
equality       = comparison ( ( "==" | "!=" ) comparison )* ;
comparison     = addition ( ( "<" | ">" | "<=" | ">=" ) addition )* ;
addition       = multiplication ( ( "+" | "-" ) multiplication )* ;
multiplication = unary ( ( "*" | "/" | "%" ) unary )* ;
unary          = ( "!" | "-" ) unary | postfix ;
postfix        = primary ( call | index | field_access | "?" )* ;

call           = "(" arg_list? ")" ;
arg_list       = expr ( "," expr )* ;
index          = "[" expr "]" ;
field_access   = "." IDENT ;

primary        = INT | FLOAT | STRING | "true" | "false" | "null"
               | IDENT
               | "(" expr ")"
               | array_lit
               | object_lit
               | lambda
               | pipeline
               | must_expr
               | ask_expr
               | freeze_expr
               | spread_expr
               | await_expr
               | struct_init
               | where_filter
               | pipe_chain
               | block_expr ;

array_lit      = "[" ( expr ( "," expr )* ","? )? "]" ;
object_lit     = "{" ( field_init ( "," field_init )* ","? )? "}" ;
field_init     = ( IDENT | STRING ) ":" expr | IDENT ;
lambda         = "|" param_list? "|" ( expr | block ) ;
pipeline       = expr "|>" expr ;
must_expr      = "must" expr ;
ask_expr       = "ask" expr ;
freeze_expr    = "freeze" expr ;
spread_expr    = "..." expr ;
await_expr     = ( "await" | "hold" ) expr ;
struct_init    = IDENT "{" ( IDENT ":" expr ( "," IDENT ":" expr )* ","? )? "}" ;
where_filter   = expr "where" IDENT comparison_op expr ;
pipe_chain     = "from" expr ( "keep" expr | "sort" "by" IDENT | "take" expr )+ ;
block_expr     = "{" statement* expr "}" ;

Types

type_ann       = simple_type
               | array_type
               | generic_type
               | function_type
               | optional_type ;

simple_type    = "Int" | "Float" | "String" | "Bool" | "Json" | IDENT ;
array_type     = "[" type_ann "]" ;
generic_type   = IDENT "<" type_ann ( "," type_ann )* ">" ;
function_type  = "fn" "(" ( type_ann ( "," type_ann )* )? ")" "->" type_ann ;
optional_type  = type_ann "?" ;

Lexical Elements

IDENT          = ( ALPHA | "_" ) ( ALPHA | DIGIT | "_" )* ;
INT            = DIGIT+ ;
FLOAT          = DIGIT+ "." DIGIT+ ;
STRING         = '"' ( CHAR | ESCAPE | INTERP )* '"' ;
RAW_STRING     = '"""' CHAR* '"""' ;
INTERP         = "{" expr "}" ;
ESCAPE         = "\\" ( "n" | "t" | "r" | "\\" | '"' | "{" ) ;
NEWLINE        = "\n" | "\r\n" | ";" ;
COMMENT        = "//" CHAR* NEWLINE ;

Operator Summary

PrecedenceOperatorsAssociativity
1 (lowest)||Left
2&&Left
3== !=Left
4< > <= >=Left
5+ -Left
6* / %Left
7! - (unary)Right
8? (postfix try)Left
9 (highest). [] ()Left

Notes

  • Newlines are significant. They terminate statements unless the line ends with an operator or open delimiter.
  • Semicolons can be used as explicit statement terminators.
  • Comments start with // and extend to the end of the line.
  • String interpolation uses {expr} inside double-quoted strings. Raw strings ("""...""") do not interpolate.
  • The has keyword is not reserved. It is parsed contextually inside struct/thing bodies.

Keywords

Alphabetical list of all reserved keywords in the Forge language. Keywords cannot be used as identifiers.

Keyword Table

KeywordCategoryDescription
anyInnovationExistential check over collection
askInnovationAI/LLM prompt call
asyncClassicAsync function modifier
awaitClassicAwait an async expression
breakControl flowExit the current loop
byInnovationSort/order modifier (sort by, order by)
catchError handlingCatch block in try/catch
changeNaturalReassign a variable (change x to 10)
checkInnovationDeclarative validation
continueControl flowSkip to next loop iteration
craftNatural typeConstructor call (craft Person { })
crawlInnovationWeb scraping
defineNaturalFunction definition (alias for fn)
downloadInnovationDownload a file (download url to path)
eachNaturalIterator keyword (for each x in items)
elseControl flowElse branch in if/else
emitNaturalYield a value (alias for yield)
everyInnovationInterval modifier (schedule every 5 seconds)
falseLiteralBoolean false
fnClassicFunction definition
forControl flowFor loop
forgeNaturalAsync function modifier (alias for async fn)
freezeInnovationMake a value immutable
fromNaturalSource keyword (grab x from url, from x import y)
giveNatural typeImpl block (alias for impl)
grabNaturalFetch from URL (grab resp from "url")
holdNaturalAwait expression (alias for await)
ifControl flowConditional branch
implClassicImplementation block
importModuleImport from a module
inControl flowIterator membership (for x in items)
interfaceClassicInterface definition
keepInnovationFilter in pipe chain
letClassicVariable declaration
limitInnovationLimit results in query pipeline
loopControl flowInfinite loop
matchControl flowPattern matching
mustInnovationCrash on error with clear message
mutClassicMutable modifier
nahNaturalElse branch (alias for else)
nullLiteralNull value
orderInnovationOrder results in query pipeline
otherwiseNaturalElse branch (alias for else)
powerNatural typeInterface definition (alias for interface)
promptInnovationPrompt template definition
pubVisibilityPublic visibility modifier
repeatNaturalCounted loop (repeat 5 times { })
retryInnovationAutomatic retry (retry 3 times { })
returnControl flowReturn from function
safeInnovationNull-safe execution block
sayNaturalPrint with newline (alias for println)
scheduleInnovationCron-style scheduling
secondsNaturalTime unit for wait and timeout
selectInnovationSelect fields in query pipeline
setNaturalVariable declaration (alias for let)
spawnConcurrencySpawn a concurrent task
structClassicStruct definition
tableInnovationTabular data display
takeInnovationTake N items in pipe chain
theNatural typeConnector (give X the power Y)
thingNatural typeStruct definition (alias for struct)
timeoutInnovationTime-limited execution block
timesNaturalLoop count modifier (repeat 5 times)
toNaturalAssignment target (set x to 5, download url to path)
transformInnovationData transformation
trueLiteralBoolean true
tryError handlingTry block in try/catch
typeClassicAlgebraic data type definition
unlessInnovationPostfix conditional negation
unpackNaturalDestructuring (alias for let { })
untilInnovationPostfix loop termination
waitNaturalSleep with time units (wait 2 seconds)
watchInnovationFile change detection block
whenInnovationGuard-based conditional
whereInnovationCollection filter
whileControl flowWhile loop
whisperNaturalPrint in lowercase
yieldClassicYield a value from a generator
yellNaturalPrint in uppercase

Built-in Type Names

These identifiers are recognized as type annotations. They are reserved in type position but can be used as regular identifiers in value position.

NameType
Int64-bit integer
Float64-bit float
StringUTF-8 string
BoolBoolean
JsonJSON value

Categories

CategoryCountKeywords
Classic10async, await, fn, impl, interface, let, mut, struct, type, yield
Control flow12break, continue, else, for, if, in, loop, match, return, while, each, from
Natural13change, define, emit, forge, grab, hold, nah, otherwise, say, set, to, unpack, whisper, yell
Natural type5craft, give, power, the, thing
Innovation21any, ask, by, check, crawl, download, every, freeze, keep, limit, must, order, prompt, retry, safe, schedule, select, table, take, timeout, transform, unless, until, watch, when, where
Error handling2catch, try
Concurrency1spawn
Literal3false, null, true
Natural time2repeat, seconds, times, wait
Visibility1pub
Module1import

Non-Keywords

The following identifiers are not reserved keywords. They are built-in functions or contextual identifiers that can be shadowed:

  • has – parsed contextually inside struct/thing bodies
  • print, println – built-in functions, not keywords
  • Ok, Err, Some, None – built-in constructors
  • self – not reserved; methods receive self as a regular parameter name
  • Module names (math, fs, io, etc.) – pre-loaded global variables, not keywords

Operator Precedence

Operators listed from lowest precedence (evaluated last) to highest precedence (evaluated first). Operators at the same precedence level are evaluated according to their associativity.

Precedence Table

LevelOperatorDescriptionAssociativity
1||Logical ORLeft
2&&Logical ANDLeft
3== !=EqualityLeft
4< > <= >=ComparisonLeft
5+ -Addition, subtractionLeft
6* / %Multiply, divide, moduloLeft
7! - (unary)Logical NOT, negationRight (unary)
8?Postfix try (Result)Left
9. [] ()Access, index, callLeft

Special Operators

These operators do not fit neatly into the arithmetic precedence chain.

Pipe Operator |>

let result = data |> transform |> validate

The pipe operator has lower precedence than function calls but higher than assignment. It passes the left-hand value as the first argument to the right-hand function.

Pipe Right >>

from users >> keep where active >> sort by name >> take 5

Used in query-style pipe chains. Evaluated left to right.

Spread ...

let merged = [...arr1, ...arr2]
let combined = { ...obj1, ...obj2 }

Prefix operator used inside array and object literals. Not a general expression operator.

Range ..

let r = 1..10

Creates a range value. Used primarily in for loops and slice operations.

Arrow ->

match x {
    1 -> "one",
    _ -> "other",
}

Used in match arms and when arms to separate pattern from result. Not a general operator.

Fat Arrow =>

let f = (x) => x * 2

Lambda shorthand syntax. Separates parameters from body.

Compound Assignment

OperatorEquivalent
+=x = x + value
-=x = x - value
*=x = x * value
/=x = x / value

Compound assignment operators have the same precedence as regular assignment (=). They are statement-level constructs, not expressions.

Type Operators

OperatorContextDescription
:let x: Int = 5Type annotation
?fn f(x: Int?) { }Optional type modifier
<>Array<Int>Generic type parameters

Type operators appear only in type annotation positions and do not participate in expression evaluation.

Examples

// Precedence determines evaluation order
let x = 2 + 3 * 4        // 14 (not 20)
let y = !true || false    // false (! binds tighter than ||)
let z = 1 < 2 && 3 > 1   // true (&& binds looser than < and >)

// Postfix try with field access
let name = get_user()?.name  // ? applies to get_user(), then .name

// Pipe with arithmetic
let result = 5 + 3 |> double  // double(8), not 5 + double(3)

Gotchas

  • Unary - binds tighter than binary operators: -2 * 3 is (-2) * 3 = -6, not -(2 * 3) = -6 (same result in this case, but matters for method calls).
  • The ? operator binds tighter than ., so expr?.field works as expected: it tries expr, then accesses .field on the result.
  • There is no ternary ? : operator. Use if/else expressions or when guards instead.
  • == and != compare by value for all types. There is no identity comparison operator.

Changelog

v0.3.3

Type system – natural syntax

  • Added thing keyword as alias for struct
  • Added power keyword as alias for interface
  • Added give keyword as alias for impl
  • Added give X the power Y syntax for interface implementation
  • Added craft keyword for struct construction
  • Added has contextual parsing inside struct/thing bodies (not a reserved keyword)
  • Interface satisfaction checking (Go-style structural typing)

v0.3.2

Landing page and book

  • Added mdBook-based language specification
  • Created project landing page
  • Documentation improvements across all modules

v0.3.1

Bug fixes and stabilization

  • Fixed production gaps identified by architecture audit
  • Comprehensive integration test suite (179 tests)
  • Stability improvements for interpreter, VM, and JIT tiers

v0.3.0

Standard library expansion

  • Expanded stdlib to 16 modules with 230+ functions
  • Added time module (25 functions: now, unix, parse, format, diff, add, sub, zone, etc.)
  • Added npc module (16 fake data generators)
  • Added csv module (parse, stringify, read, write with auto type inference)
  • Added term module (25+ functions: colors, table, sparkline, bar, banner, box, gradient, menu, etc.)
  • Added exec module (run_command with stdout/stderr/status)
  • Expanded http module with download and crawl
  • Expanded fs module to 20 functions
  • Native Option<T> values (Some, None, is_some, is_none)
  • Tokio spawn with language-level task handles
  • Language-level channels (channel, send, receive, try_send, try_receive)
  • GenZ debug kit (sus, bruh, bet, no_cap, ick)
  • Execution helpers (cook, yolo, ghost, slay)
  • 30 interactive tutorials via forge learn
  • Shell integration (sh, shell, sh_lines, sh_json, sh_ok, pipe_to)
  • Dual syntax (natural language keywords alongside classic syntax)
  • Innovation keywords: when, must, safe, check, retry, timeout, schedule, watch, ask, download, crawl, repeat, wait, grab, emit, hold
  • Pipe operator (|>) and query-style pipe chains
  • where filter syntax
  • freeze for immutable values
  • Decorator system (@server, @get, @post, @put, @delete, @ws)
  • HTTP server powered by axum + tokio
  • WebSocket support
  • PostgreSQL support via pg module
  • Compound assignment operators (+=, -=, *=, /=)
  • Spread operator (...) in arrays and objects
  • String interpolation ("hello, {name}")
  • Raw strings ("""no interpolation""")
  • Destructuring (let {a, b} = obj / unpack {a, b} from obj)
  • Default parameter values
  • 12 example programs
  • 13 CLI commands (run, repl, version, fmt, test, new, build, install, lsp, learn, chat, help, -e)

v0.2.0

VM and JIT tiers

  • Register-based bytecode VM (--vm flag)
  • 32-bit instruction encoding (ABC, ABx, AsBx formats)
  • Mark-sweep garbage collector for VM heap
  • Green thread scheduler in VM
  • Cranelift JIT compiler (--jit flag)
  • JIT achieves ~11x faster than Python on fib(30)
  • Bytecode compiler from AST to VM instructions
  • VM supports core language features (variables, functions, closures, control flow, arrays, objects)
  • JIT supports integer/float arithmetic, function calls, recursion, basic control flow

v0.1.0

Initial release

  • Tree-walking interpreter in Rust
  • Core language: variables, functions, closures, if/else, for, while, match
  • Basic type system: Int, Float, Bool, String, Array, Object, Null
  • Result type (Ok, Err) with ? operator
  • math, fs, io, crypto, db, env, json, regex, log modules
  • Built-in HTTP client via reqwest
  • SQLite database access via rusqlite
  • REPL mode
  • Formatter (forge fmt)
  • Test runner (forge test)
  • Project scaffolding (forge new)