Language Spec

The terse reference manual: grammar, operators, literal forms, and exact rules. Organized for lookup — see the Overview for a guided, start-to-finish read.

Guiding Principles

  • Minimal keyword surface — if the grammar resolves it structurally, don't burn a keyword
  • Explicit over implicit — casts, visibility, and types are never magic
  • GC'd runtime — no manual memory management, no pointer syntax
  • Bytecode VM target — interpreter first, native compilation later
  • Zero values are always valid — every type has a well-defined zero
  • Three distinct collection types — string, [T] (list), [K, V] (map) — unified by shared protocols (iteration, indexing, len) rather than one being sugar for another

nil exists as a real value (produced by json.parse of null, reported by type_of as "nil", and returned when reading an absent map key), but no type's zero is nil — a fresh int is 0, a string is "", a collection is {}. nil is only ever assignable to a slot whose static type includes it (an optional T? or a union T | U that lists nil); it is never the zero of a concrete type — see "Optionals & Unions" below.


Reserved Keywords

if  elseif  else  for  in  return  break  continue
import  as  struct  enum  interface  true  false
match  defer  nil

Imports

import "strings"          // binds as `strings`
import "math" as m        // binds as `m`
import "models/entity"    // binds as `entity`

Printing needs no import — print/println/printf are always-available global builtins (see the stdlib reference).

Resolution Order

Every module is cached by import path after its first successful import (a module executes once; every later import of the same path returns the cached namespace) — and native modules (os/fs/math/strings/conv/time/ path/json/regex/encoding/random/crypto/csv/net) are pre-registered in that same cache at startup, before any file search ever runs. So for import "strings", if strings names a native module, the cached native module is returned immediately — a local ./strings.zn is never even looked for. This resolution order only applies when the path ISN'T already cached (i.e. it doesn't name a native module):

1. <script_dir>/<path>.zn
2. <script_dir>/<path>/mod.zn
3. $ZINC_PATH/<path>.zn        (only checked if $ZINC_PATH is set)
4. $ZINC_PATH/<path>/mod.zn    (only checked if $ZINC_PATH is set)
5. the stdlib embedded in the interpreter binary (see "Embedded Stdlib" below)
6. the native module cache (redundant with the pre-registration above, but
   also where a loaded .dll/.so extension module ends up)

So a local .zn file DOES shadow a same-named stdlib .zn module found later in this list (steps 3–5) — but can never shadow a native module, which is resolved before step 1 is ever reached. There is no shadowing warning of any kind, for either case. A runtime error ("cannot find module") is reported only if nothing at all matches; there's no separate check for "both a .zn file and a mod.zn directory exist at the same tier" — the search takes whichever it finds first (step 1 before step 2) and never notices the other.

ZINC_PATH does not need to be set. The stdlib (algo, async, io, log, math_ext, queue, result, set, strbuilder, strings_ext, testing, plus the auto-loaded default.zn prelude) is embedded in the interpreter binary at compile time, so a bare zinc.exe with no $ZINC_PATH and no on-disk stdlib/ directory can still import any of them. Set ZINC_PATH only when you want to override the embedded copy with an on-disk one (e.g. while developing the stdlib itself).

Rules

  • No selective imports — always import the full module namespace
  • as aliases the bound name: import "math" as mm.sqrt(...)
  • Circular imports are a runtime error (not a compile-time one)
  • Import order within a file does not matter — all imports are resolved before execution
  • A module is executed once on first import; subsequent imports return the cached namespace
  • A directory import requires a mod.zn entry file inside it

Visibility

  • name — public by default
  • _name — private to the module
  • _ — discard identifier, write-only sink, not a binding
PI :: 3.14            // public constant
_secret :: 42         // private constant

Entity :: struct {
    id:     int       // public field
    _cache: [string]  // private field (convention only — see note below)
}

Module-level _name privacy is enforced: an importing script cannot reference a module's _-prefixed top-level binding at all (undefined-name error). Struct field/method privacy is not enforced the same way — a leading underscore on a struct field or method is a naming convention signaling "internal, don't touch," but any importing script can still read and write _-prefixed fields on a struct instance with no compile-time or runtime check. Stdlib containers (result, queue, set, log, strbuilder, testing, …) rely on callers respecting this convention voluntarily.


Variables & Constants

The declaration grammar is name : [T] = value (mutable) or name : [T] : value (immutable). The type slot is always optional. := and :: are the shorthand inferred forms.

x: int = 42          // mutable, annotated
y := 3.14            // mutable, inferred
PI: float : 3.14     // immutable, annotated
MAX :: 100           // immutable, inferred

Reassigning an immutable binding is a compile error.


Collections

Zinc has three distinct collection typesstring, [T] (list), and [K, V] (map) — unified by a small set of shared protocols rather than one being sugar for another (decision 7). Every collection supports:

  • iterationfor x in coll (and for k, v in coll on lists/maps),
  • indexingcoll[k],
  • lengthlen(coll).

Behaviour that differs between them is honest and type-specific (see below): strings are immutable, lists grow by append, maps insert by key assignment.

Lists

[T] — an ordered, integer-indexed, growable sequence of T.

amts: [int] = {10, 20, 30}
amts[0] = 99            // index assignment (in bounds)
append(amts, 40)        // grow
println(contains(amts, 99))   // value search
  • index assignment updates an existing position; append is the only way to grow,
  • On a list, has(list, v) is also element/value membership — has({10, 20}, 1) is false (1 isn't held as a value; it's not testing "is index 1 valid"). has and contains agree on lists; the distinction only matters for maps, where has(map, k) tests keys and contains(map, v) tests values.

Maps

[K, V] — explicitly keyed. Valid key types: int, string, bool, and enum types.

stats: [string, int] = {
    "strength":     10,
    "intelligence": 12,
}
stats["strength"] = 9

Reading an absent key returns nil (not the value type's zero), so you can distinguish "missing" from "present". Test presence with has(stats, "luck"). Maps preserve insertion order for iteration and expose keys(m), values(m), and delete(m, key). They are backed by a hash table (O(1) get/set/has/delete).

Pass-by-Reference

Lists and maps are reference types: assignment aliases them and there is no copy-on-assignment. There is no built-in deep-copy helper yet — an explicit copy() is planned (see docs/roadmap-to-1.0.md decision 9) but not shipped; for now, build an independent copy manually (e.g. for x in xs { append(ys, x) } for a list). string is a value type and immutable — there is no string index assignment; build new strings with +, slicing, or the strbuilder module.

Literal Disambiguation

The comma inside [...] is the sole discriminator between list and map types:

  • [T] — single argument, always a list
  • [K, V] — two arguments, always a map

Literal inference:

  • Bare values {10, 20, 30} — inferred as [T]
  • Colon-pairs {"foo": 1} — inferred as [K, V]
  • Empty {} — zero/default value of the annotated type (see below)

Default Literals

{} is the default/zero value literal. It is valid for any type — collections, structs, and primitives — and resolves to the zero value of the annotated type at compile time: a typed declaration (x: T = {}), a return against a declared return type, or a struct-literal field value (Entity { stats = {} }, resolved against that field's declared type) all work. A bare {} with no resolvable type context (e.g. an untyped x := {}, or an assignment RHS) is a compile error ("cannot infer type of {}") rather than silently becoming int 0 — use Type{} or a typed declaration instead. {} also has no zero value for a union type that doesn't include nil (e.g. x: int | string = {}) — that's a compile error too.

x: int = {}           // 0
y: bool = {}          // false
z: [int] = {}         // empty list
e: Entity = {}        // zero-value struct

Strings

string is its own immutable type (UTF-8 under the hood). Conceptually a sequence of Unicode codepoints: len, indexing, and iteration operate by codepoint, not byte, so string[i] always returns a complete character and len("café") is 4. Slicing s[a:b] is codepoint-based and returns a new string. Strings do not support index assignment (they are immutable).

name := "hello, world"

Standard escape sequences: \n, \r, \t, \0, \, \", \'. Arbitrary codepoints are also writable: \xNN (exactly two hex digits, one byte) and \u{HHHH} (a Unicode codepoint, written as UTF-8). Both forms are valid inside string and char literals.


Arithmetic & Casting

sum      := x + y
div, mod := x / 3, x % 5

Explicit cast via type-as-function — no implicit promotion between numeric types:

z := int(3.14)      // z = 3
n := f32(my_i32)

Conditionals

if x > 10 {
    println("big")
} elseif x == 10 {
    println("exact")
} else {
    println("small")
}

Match

match is a multi-way equality branch over a subject expression, with an optional else default:

classify :: (n: int) => string {
    match n {
        0 => { return "zero" }
        1 => { return "one" }
        else => { return "many" }
    }
    return "unreachable"
}
  • Each arm's label is compared to the subject with == (works for int, string, bool, and enum values — e.g. Dir.North => { ... }).
  • else is optional; if no arm matches and there is no else, match does nothing (falls through) rather than erroring.
  • Arms are blocks ({ ... }); return/break/continue inside an arm behave as they would anywhere else in that enclosing function/loop.

Loops

Conditional

i := 0
for i < 5 {
    i = i + 1
}

Iteration

// list — value first, index optional
for value in amts { }
for index, value in amts { }

// map — key optional
for value in stats { }
for key, value in stats { }

// range — stdlib builtin returning a list of ints
for n in range(0, 10) { }
for n in range(0, 10, 2) { }   // with step

for key, value is valid only on lists and maps. for-in only works on the built-in iterable kinds (list, map, string) — see "Iterator Protocol (stdlib)" below for the (not yet implemented) user-defined iterator design.

Control Flow

break       // exit loop
continue    // next iteration
return      // early return from function

Functions

Functions are first-class values. Top-level functions are conventionally immutable (::) but the binding operator is the programmer's choice.

// single return
add :: (a: int, b: int) => int {
    return a + b
}

// multiple return — parens always required
divmod :: (a: int, b: int) => (int, int) {
    return a / b, a % b
}
q, r := divmod(17, 5)

// no return — `=>` is still required, with no return type before the body
greet :: () => {
    println("Hello, world!")
}

/* variadic — must be the last parameter, desugars to [int] inside the body.
   The type checker doesn't check a variadic call's argument types at all
   (it stays gradual there), so passing a list directly instead of spreading
   it isn't caught at compile time — it fails at RUNTIME, inside the callee,
   once the mismatched element type is actually used (see the spread note
   below for the correct way to pass a list).
*/
sum_all :: (args: ..int) => int {
    total := 0
    for v in args {
        total = total + v
    }
    return total
}
sum_all(1, 2, 3)   // correct
sum_all({1, 2, 3}) // compiles, but fails at runtime — args becomes [{1,2,3}],
                    // a 1-element list whose element is itself a list, not
                    // spread implicitly; `total + v` then mixes int and list

// spread — `..expr` forwards the elements of a list into a variadic call.
// This is the sanctioned way to pass a list to a variadic parameter, and how
// one variadic function forwards its args to another.
nums := {1, 2, 3}
sum_all(..nums)    // correct — spreads the list into individual arguments

// function as value
op: (int, int) => int = add

Function Types

  • Single return: (int, int) => int
  • Multiple return: (int, int) => (int, bool) — parens always required for multi-return
  • No return: (int, int)

Multi-return functions CAN be stored as values and passed as callbacks like any other function — op := divmod; q, r := op(17, 5) works fine. (A struct is still the better choice for a complex/named return shape; this is a style recommendation, not a limitation.)

Closures

Inner functions capture variables from enclosing scopes. Captured variables are heap-allocated by the VM when they escape their declaring scope.

make_counter :: () => () => int {
    count := 0
    increment := () => int {
        count = count + 1
        return count
    }
    return increment
}

c := make_counter()
c()   // 1
c()   // 2

Defer

defer expr schedules expr to run when the enclosing scope exits — Odin-style LIFO ordering, but resolved fresh at every exit path rather than compiled once at the defer site:

lifo :: () => {
    defer println("A")
    defer println("B")
    defer println("C")
    println("body")
}
lifo()
// body, C, B, A — reverse declaration order

early :: (x: int) => int {
    defer println("cleanup")
    if x > 0 {
        return 1     // defer runs after the return value is computed, before returning
    }
    return 0
}
  • Runs on normal fall-through, return, break, and continue — every exit a scope can take.
  • Multiple defers in the same scope run in LIFO (reverse declaration) order.
  • A defer inside a loop body re-registers each iteration, so it fires once per iteration, not once for the whole loop.
  • The deferred expression is evaluated at the moment each exit path fires, using whatever local state exists then — not snapshotted at the defer statement itself. A defer that reads a variable mutated after the defer line sees the mutated value at exit time.

Structs

Structs are always passed by reference. There is no copy-on-assignment for struct types.

Entity :: struct {
    id:    int,
    name:  string,
    stats: [string, int],

    // static — called as Entity.new(...)
    new :: (id: int, name: string) => Entity {
        return Entity {
            id    = id,
            name  = name,
            stats = {},
        }
    },

    // method — first param is Entity, can be called as Entity.level_up(entity) or entity->level_up()
    level_up :: (entity: Entity) => {
        for stat, value in entity.stats {
            entity.stats[stat] = value + 1
        }
    }
}

ent := Entity.new(1, "Goblin")
ent->level_up()

Key Rules

  • There is no self keyword. Whether a struct member function is a method or a static is decided structurally: if its first parameter's type annotation names the enclosing struct (e.g. entity: Entity inside Entity), it's a method; otherwise it's a static. The parameter can be named anything — entity, self, e — only the type is checked.
  • No inheritance, no embedding — use explicit composition (orc.base.name)
  • Struct literals use = for field assignment, prefixed with the type name: Entity { id = 1 }
  • Map literals use : for key-value pairs: { "strength": 10 }
  • Zero value of any struct is always valid — new statics are convenience, not requirements

Method Call Syntax

receiver->method(args) desugars to ReceiverType.method(receiver, args). Resolution is struct-scoped only — -> looks up the method exclusively in the receiver's own struct namespace.


Enums

Direction :: enum {
    North,
    East,
    South,
    West,
}

d: Direction = Direction.North
d2: Direction = .South   // shorthand — type is known from annotation

When the target type is known from context (variable annotation, function parameter, return position), enum variants may be written with a leading dot: .North instead of Direction.North.

Enum types are valid map key types.


Interfaces

Interfaces are satisfied implicitly — any struct with matching method signatures satisfies the interface without explicit declaration.

Drawable :: interface {
    draw: (Drawable)
    bounds: (Drawable) => (int, int)
}
  • There is no self keyword; by convention the interface's own name (e.g. Drawable) is used as the first parameter's type, standing in for "whatever struct implements this." Satisfaction checking (below) matches on method name, parameter count, and each non-receiver parameter/return type; the receiver placeholder's type itself is not compared.
  • Interface bodies declare method shapes only — no fields
  • Omitting => means no return value
  • A struct satisfies an interface if it has all declared methods with matching signatures
Entity :: struct {
    draw :: (self: Entity) => {
        // ...
    }

    bounds :: (self: Entity) => (int, int) {
        return 10, 20
    }
}

// Entity implicitly satisfies Drawable
drawables: [Drawable] = {ent}

Iterator Protocol (stdlib) — planned, not yet implemented

The eventual design (see docs/roadmap-to-1.0.md's Phase 4 remaining item):

Iterator :: interface {
    next: (Iterator) => (any, bool)
}

next would return the current value and a continuation bool. When bool is false, iteration stops. Multi-value results should use a struct — next is always exactly (any, bool).

Not implemented yet: for x in val only works today on the built-in iterable kinds (list, map, string). A struct satisfying Iterator above does NOT make for x in val work — it's a runtime error ("value is not iterable"). Deferred post-1.0: calling a Zinc next() from inside the VM's Iter_Next needs re-entrancy work the roadmap itself defers.


Error Handling

No dedicated error type or syntax. Use multi-return with a bool or a descriptive error struct:

find_entity :: (id: int) => (Entity, bool) {
    return some_entity, true    // found
    return {}, false            // not found
}

result, ok := find_entity(1)
if !ok {
    return {}, false
}

No exceptions. panic(msg?) and assert(cond, msg?) are deliberate, unrecoverable aborts — "this should never happen, crash loudly with a stack trace" — distinct from os.exit and from the recoverable (value, bool) / T | Error pattern above:

assert(x > 0, "x must be positive")
panic("unreachable state")

64-bit integer overflow wraps rather than trapping. nil is a real value but is never the zero of a typed slot (see Guiding Principles and "Optionals & Unions" below).


Optionals & Unions

Zinc's static checker (src/types.odin) has real tagged union types T | U, with T? as sugar for T | nil. any is the fully-open union — it accepts anything and is how json.parse and other genuinely dynamic boundaries stay honest without disabling type checking everywhere.

v: int | string = 5      // ok — int is a member
v = "hello"               // ok — string is a member
v = true                  // compile error — bool is not a member

g :: (x: int?) => int {   // int? == int | nil
    if x != nil {
        return x          // narrowed to int inside this branch
    }
    return -1
}
println(g(5))
println(g(nil))
  • A variable still has one fixed static type (Rule B); a union type is that fixed type, so reassigning within the union's members is fine, but assigning a value outside it is a compile error.
  • nil is a real value and a real literal, but it type-checks only against a union/optional that lists it — it is never a concrete type's zero (see "Zero Values").
  • Flow-narrowing: inside an if x != nil { ... } branch (and equivalent match arms), a T | U value narrows to the checked member, so it can be used as that concrete type without an explicit cast.
  • Every var has a single, statically-known type fixed at declaration; reassigning a variable to an incompatible concrete type is a compile error (e.g. t := "s"; t = 3 fails at compile time). Undetermined types stay permissive (unknown) so dynamic and stdlib code keep working.
  • The stdlib under $ZINC_PATH is exempt from checking until it is migrated to generics; user-imported modules are checked like the entry script.

Generics

Functions can declare type parameters, inferred at the call site — no explicit type arguments needed:

id :: <T>(x: T) => T { return x }
println(id(42))       // T = int
println(id("hello"))  // T = string

first :: <T, U>(a: T, b: U) => T { return a }
println(first(1, "ignored"))

head :: <T>(xs: [T]) => T { return xs[0] }
nums: [int] = {10, 20, 30}
println(head(nums))   // T = int
  • Type parameters are declared right after ::, before the parameter list: name :: <T, U>(x: T) => T { ... }.
  • A call to an unqualified function name in the same file — generic or not — is argument-type checked; unification infers T/U from the actual argument types. A call through an imported module (some_module.f(...)) is not yet checked this way — the checker doesn't track a module's exports, so those calls stay gradual until that's implemented.
  • Type parameters erase at runtime — there is no reified generic type, just ordinary checked calls.
  • Generic structs (e.g. a first-class Queue<T>) are not yet implemented. algo is migrated to real <T>/<T,U>/<T,K> generic functions. queue and result are the two stdlib modules that actually need generic structs to be type-safe (their payload lives in a struct field, not a bare list/map) and stay any-typed until then; set never used any to begin with (string-keyed).

Primitive Types

Numeric Tower

TypeInternalNotes
i88-bit signed
i1616-bit signed
i3232-bit signed
i6464-bit signed
u88-bit unsigned
u1616-bit unsigned
u3232-bit unsigned
u6464-bit unsigned
f3232-bit float
f6464-bit float
intalias for i64
floatalias for f64

Arithmetic is performed at 64-bit width (int = i64, float = f64). The narrow numeric types (i8u32, f32) are storage/interop targets: a value is truncated to the target width only by an explicit cast (u8(x), i32(x)), not by ordinary arithmetic. i64/u64 arithmetic wraps at the 64-bit boundary. Mixed-type arithmetic and comparison require explicit casting — no implicit promotion between int and float.

Other Primitives

TypeInternalLiteralNotes
boolbooltrue/false
charu32'a'UTF-32 codepoint, alias for u32
stringUTF-8 bytes"hello"codepoint-indexed (len/index/iterate count codepoints, not bytes); immutable — no index assignment
anycompiler builtin — the fully-open union; valid anywhere a type can appear: params, fields, return types, declarations, not just interface signatures

Zero Values

Every type has a valid zero value, and no concrete type's zero is nil — see "Optionals & Unions" below for the one place nil actually lives.

TypeZero value
int, i32, etc0
float, f320.0
boolfalse
string""
char'\0'
[T]{}
[K, V]{}
struct{}

Token Reference

Operators

OperatorMeaning
:declaration / type annotation
=mutable assignment
:=inferred mutable declaration
::immutable binding
=>return type separator
->method call
.field / namespace access
+ - * / %arithmetic
+= -= *= /= %= &= |=compound assignment
== != < > <= >=comparison
&& || !logical
..variadic parameter marker / call-site spread

There are no bitwise operators. &= and |= are the compound forms of the logical && / ||. There is no in expression operator — test map/list membership with the has(collection, key) builtin. Relational operators (< > <= >=) compare two ints, two floats, two chars, or two strings (lexicographic); any other operand pairing is a runtime error.

Delimiters

( ) { } [ ] ,

Literal Forms

FormType
42int
3.14float
"hello"string
'a'char
true / falsebool
{...}list or map literal
Type {...}struct literal

Integer literals also accept a radix prefix — 0x1F (hex), 0b1010 (binary), 0o17 (octal), prefix letter case-insensitive (0X/0B/0O all work) — and _ as a visual digit separator anywhere in a numeric literal (1_000_000). Float literals accept scientific notation (1e3, 2.5e-2).

On this page