Syntax & Types
Logos is dynamically typed — you declare variables with let, and the interpreter
figures out the rest. This page covers all the core syntax.
Variables
Variables are declared with let and are mutable by default. Reassign with = or use compound operators like +=:
// All basic types
let name = "Uthman" // string
let age = 20 // integer
let score = 3.14 // float
let active = true // boolean
let nothing = null // null
// nil is also accepted (canonical form is null)
let empty = nil
// Reassign freely — no type annotations needed
age = 21
// Compound assignment
let count = 0
count += 5 // 5
count -= 2 // 3
// Postfix increment/decrement (v0.4+)
let i = 0
i++ // 1
i-- // 0Note: Both null and nil are valid. null is the
canonical form used internally.
Gotcha: Use const for immutable bindings. Reassignment throws a runtime error.
// const for immutable bindings (v0.4.2)
const PI = 3.14159
const MAX_RETRIES = 3
// Mutable with let
let counter = 0
counter++ // OK
// This throws a runtime error:
// PI = 3.14 // Error: cannot reassign constFunctions
Functions are first-class values — assign them to variables, pass them around, return them
from other functions. Define with fn:
// Named function
fn greet(name) {
return "Hello, " + name + "!"
}
// Anonymous function assigned to variable
let double = fn(x) {
return x * 2
}
// Arrow syntax for single-expression functions (implicit return)
let triple = fn(x) -> x * 3
// Call them
print(greet("World")) // Hello, World!
print(str(double(5))) // 10When to use arrow functions
Use arrow syntax (fn(x) -> x * 2) for short, pure transformations —
map/filter callbacks, simple calculations. Use full fn(x) { ... } syntax
for multi-statement functions, loops, or complex logic.
Both forms are equivalent — choose based on readability:
// These two functions do the same thing:
fn double(x) -> x * 2 // arrow syntax (implicit return)
fn double(x) { return x * 2 } // block syntax (explicit return)Closures
Functions capture their surrounding scope. This lets you create factory functions that produce specialized behavior:
// makeAdder returns a new function that "remembers" x
let makeAdder = fn(x) {
return fn(y) -> x + y
}
let add5 = makeAdder(5)
let add10 = makeAdder(10)
print(str(add5(3))) // 8
print(str(add10(3))) // 13
// Classic counter example
let makeCounter = fn() {
let count = 0
return fn() {
count += 1
return count
}
}
let counter = makeCounter()
print(str(counter())) // 1
print(str(counter())) // 2
print(str(counter())) // 3Why closures matter
Closures replace the need for classes or objects in many situations. They let you create private state, factory functions, and function factories without complex boilerplate.
Control Flow
If / Else
Branching works like most languages. Chain with else if:
let age = 20
if age >= 65 {
print("senior discount!")
} else if age >= 18 {
print("adult fare")
} else {
print("child fare")
}
// Ternary operator for simple cases (v0.3.2+)
let price = age >= 65 ? 5 : age >= 18 ? 10 : 5
print("Ticket: $" + str(price))While-style For Loops
For loops with a condition run until the condition is false. It's a while loop with different syntax:
let i = 0
for i < 5 {
print(str(i))
i += 1
}
// Prints: 0, 1, 2, 3, 4For-In Loops
Iterate over arrays or table keys. Use the index variant when you need the position:
let fruits = ["apple", "banana", "mango"]
// Element only
for fruit in fruits {
print(fruit)
}
// With index — i is position, v is value
for i, v in fruits {
print(str(i) + ": " + v)
}
// Prints: 0: apple, 1: banana, 2: mangoNumeric Ranges v0.4.2
Use range(start, end, step?) for numeric iteration. End is exclusive —
it stops before reaching the end value:
// 0 to 10 (exclusive), so prints 0-9
for i in range(0, 10) {
print(i)
}
// With step (default is 1)
for i in range(0, 10, 2) {
print(i) // 0, 2, 4, 6, 8
}
// Countdown with step=-1
for i in range(5, 0, -1) {
print(i) // 5, 4, 3, 2, 1
}
// Note: range(0, 10) excludes 10
// range(5, 0, -1) excludes 0Switch
Switch statements are useful when you have multiple conditions on the same value.
Each case runs its block and then exits (no fallthrough):
let role = "admin"
switch role {
case "admin" {
print("full access")
}
case "editor" {
print("can edit")
}
case "viewer" {
print("read only")
}
default {
print("no access")
}
}Arrays
Arrays are ordered collections. They can hold mixed types. Access by zero-based index:
let scores = [95, 87, 92, 78, 88]
// Access by index
let first = scores[0] // 95
let last = scores[4] // 88
// Update an element
scores[0] = 100
// Array length
print(str(len(scores))) // 5
// Mix types (generally avoid this in practice)
let mixed = [1, "hello", true, null]
// Nested arrays
let matrix = [[1, 2], [3, 4]]Common array operations
Logos has built-in functions for common operations. The stdlib has more powerful versions:
let nums = [1, 2, 3, 4, 5]
push(nums, 6) // Add to end
prepend(nums, 0) // Add to start
pop(nums) // Remove and return last
first(nums) // First element (1)
last(nums) // Last element (5)
tail(nums) // Everything except first
reverse(nums) // Reversed copy
sort(nums) // Sorted (v0.4.1: strings or numbers)
contains(nums, 3) // trueTables
Tables are key-value collections — the equivalent of objects, dicts, or hashmaps. Use dot notation for string keys, bracket notation for dynamic keys:
let user = table{
name: "Alice",
age: 30,
active: true,
}
// Dot access (preferred for string keys)
print(user.name) // Alice
user.name = "Bob" // Mutates the field
print(user.name) // Bob
// Bracket access (for dynamic keys)
let key = "age"
print(user[key]) // 30
// Nested tables
let company = table{
name: "Acme",
address: table{
city: "Seattle",
zip: "98101",
},
}
print(company.address.city) // SeattleTable utilities
let config = table{ host: "localhost", port: 8080 }
// Get all keys or values
let k = keys(config) // ["host", "port"]
let v = values(config) // ["localhost", 8080]
// Check if key exists
has(config, "host") // true
has(config, "debug") // false
// Delete a key
let smaller = tableDelete(config, "port")
// Merge two tables
let extra = table{ debug: true, timeout: 30 }
let merged = merge(config, extra)When to use tables
Use tables for structured data — user profiles, API responses, configuration, game entities. They're more expressive than arrays when each item has named fields.
Note: Table key ordering is non-deterministic. Go maps don't preserve insertion order. This affects table comparisons — two tables with the same keys/values may appear different if compared as strings. Use explicit key-by-key comparison when order matters.
String Interpolation v0.4
Embed variables and expressions directly in strings with $${. This replaces
ugly concatenation:
// Instead of this:
print("Hello, " + name + "! You have " + str(count) + " messages.")
// Do this:
print("Hello, ${name}! You have ${count} messages.")Interpolation works with any expression — variables, calculations, function calls, array access:
let name = "World"
let nums = [1, 2, 3]
// Basic
let greeting = "Hello, ${name}!" // "Hello, World!"
// In expressions
let total = nums[0] + nums[1] + nums[2]
print("Sum: ${total}") // "Sum: 6"
// Function calls in strings
let items = ["apple", "banana"]
print("Fruits: ${join(items, ", ")}") // "Fruits: apple, banana"
// Table access
let user = table{ name: "Alice", role: "admin" }
print("${user.name} is ${user.role}") // "Alice is admin"Gotcha: No quoted strings inside $${ blocks.
Extract to a variable first:
// WRONG:
let msg = "Price: ${"$" + price}" // syntax error
// CORRECT:
let dollar = "$"
let msg = "Price: ${dollar + price}"Pipe Operator v0.4
The pipe operator (|>) passes the left value as the first argument to the
right function. It makes data transformations readable:
// Without pipe — nested function calls (hard to read)
let nums = [10, 20, 30]
let doubled = []
for n in nums {
push(doubled, n * 2)
}
// With pipe — left to right
let nums = [10, 20, 30]
let doubled = nums
|> push(_, 10)
|> push(_, 20)
|> push(_, 30)
// Or just use a loop
for n in nums {
print(n * 2)
}Pipe shines with array operations. Chain as many as you need:
// Simple pipe — pass value to a function
let name = "world"
let greeting = "Hello, " + name // Without pipe
let greeting2 = name |> upper() // With pipe
// Chain transformations
let nums = [1, 2, 3, 4, 5]
let text = str(nums) |> upper() |> replace(" ", "-")
print(text) // [1,-2,-3,-4,-5]Pipe with try
Combine pipe with try for elegant data pipelines that handle errors at the end:
// Fetch and parse with error handling
let res = try httpGet("https://api.example.com/users")
let data = try parseJson(res)
// Filter and transform without std/array
let active = []
for user in data {
if user.active {
push(active, user.name)
}
}
// If httpGet fails, error propagates immediately
// No need to check .ok at every stepTry Expression v0.4
Many built-in functions return result objects: {ok, value, error}.
Checking these manually gets tedious. The try keyword unwraps the value
and propagates errors automatically:
// Without try — verbose
fn readConfig(path) {
let res = fileRead(path)
if !res.ok {
print("Failed: " + res.error)
return null
}
let json = parseJson(res.value)
if !json.ok {
print("Invalid JSON: " + json.error)
return null
}
return json.value
}
// With try — errors propagate automatically
fn readConfig(path) {
let content = try fileRead(path)
let config = try parseJson(content)
return config
}
// If fileRead fails, readConfig returns the error immediately
// No explicit error checking neededWhen try propagates
Try unwraps the value if .ok is true, or returns a result object with .ok = false and the error message. In a function, this means the
function returns early with the error.
Postfix Operators v0.4
i++ increments and i-- decrements by 1. The value of the
expression is the old value (postfix behavior):
let n = 5
print(str(n++)) // 5 (prints old value, then increments)
print(str(n)) // 6
print(str(n--)) // 6 (prints old value, then decrements)
print(str(n)) // 5
// Common use: loop counters
for i = 0; i < 3; i++ {
print(str(i))
}
// Prints: 0, 1, 2Concurrency
spawn runs code asynchronously in parallel goroutines. The program waits for all
spawned tasks to complete before exiting. Use it when you have independent tasks:
// Spawn a single task
spawn {
print("This runs in the background")
}
// Spawn a loop — each iteration runs concurrently
let urls = [
"https://api.example.com/users",
"https://api.example.com/posts",
"https://api.example.com/comments",
]
spawn for url in urls {
let data = try httpGet(url)
cacheResult(url, data)
}
print("All requests queued") // Prints immediatelyWhen to use spawn
Good for: parallel HTTP requests, batch file processing, concurrent computations. Not for: tasks that depend on each other's results, or when order matters.
HTTP Requests
HTTP functions return result objects. Always check for errors:
// GET request
let res = httpGet("https://api.example.com/users")
if res.ok {
print(res.value.body)
} else {
print("Error: " + res.error)
}
// With try + pipe (recommended in v0.4+)
let body = try httpGet("https://api.example.com/users")
// POST with JSON body
let payload = toJson(table{ name: "Alice", email: "alice@example.com" })
let res = httpPost("https://api.example.com/users", payload)
// With custom headers
let headers = table{
"Authorization": "Bearer " + env("API_TOKEN"),
"Content-Type": "application/json",
}
let data = try httpGet("https://api.example.com/private", headers)Available methods: httpGet, httpPost, httpPut, httpPatch, httpDelete
JSON
Parse JSON strings into Logos tables, or convert tables back to JSON:
// Parse JSON string to table
let data = try parseJson('{"name": "Alice", "age": 30, "active": true}')
print(data.name) // Alice
print(str(data.age)) // 30
// Convert table to JSON string
let user = table{ name: "Bob", email: "bob@example.com" }
let json = try toJson(user)
// Pretty format for debugging
let pretty = try prettyJson(data)Gotcha: JSON numbers become Logos numbers, JSON arrays become Logos arrays, JSON objects become Logos tables. There's no round-trip type preservation — a table with integer keys might become string keys in JSON.
Modules
Import standard library modules with use. Standard lib modules are:
use "std/math" // mathFactorial, mathFib, mathIsPrime, etc.
use "std/array" // map, filter, reduce, unique, etc.
use "std/string" // strCapitalize, strReverse, etc.
use "std/path" // pathJoin, pathBase, pathDir, etc.
use "std/time" // datetimeNow, datetimeFormat, etc.
use "std/log" // logDebug, logInfo, logWarn, logError
use "std/type" // isString, isNumber, isArray, etc.
use "std/testing" // assert, suite, summaryYou can also import custom modules from the same directory:
// In myapp.lgs
use "utils"
use "helpers"
print(helperFunction())
print(utilValue)