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
asaliases the bound name:import "math" as m→m.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.znentry 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 types — string, [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:
- iteration —
for x in coll(andfor k, v in collon lists/maps), - indexing —
coll[k], - length —
len(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)isfalse(1isn't held as a value; it's not testing "is index 1 valid").hasandcontainsagree on lists; the distinction only matters for maps, wherehas(map, k)tests keys andcontains(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 forint,string,bool, and enum values — e.g.Dir.North => { ... }). elseis optional; if no arm matches and there is noelse,matchdoes nothing (falls through) rather than erroring.- Arms are blocks (
{ ... });return/break/continueinside 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, andcontinue— every exit a scope can take. - Multiple defers in the same scope run in LIFO (reverse declaration) order.
- A
deferinside 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
deferstatement itself. Adeferthat reads a variable mutated after thedeferline 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
selfkeyword. 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: EntityinsideEntity), 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 —
newstatics 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
selfkeyword; 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.
nilis 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 equivalentmatcharms), aT | Uvalue 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 = 3fails at compile time). Undetermined types stay permissive (unknown) so dynamic and stdlib code keep working. - The stdlib under
$ZINC_PATHis 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/Ufrom 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.algois migrated to real<T>/<T,U>/<T,K>generic functions.queueandresultare 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 stayany-typed until then;setnever usedanyto begin with (string-keyed).
Primitive Types
Numeric Tower
| Type | Internal | Notes |
|---|---|---|
i8 | 8-bit signed | |
i16 | 16-bit signed | |
i32 | 32-bit signed | |
i64 | 64-bit signed | |
u8 | 8-bit unsigned | |
u16 | 16-bit unsigned | |
u32 | 32-bit unsigned | |
u64 | 64-bit unsigned | |
f32 | 32-bit float | |
f64 | 64-bit float | |
int | alias for i64 | |
float | alias for f64 |
Arithmetic is performed at 64-bit width (int = i64, float = f64). The narrow numeric types (i8…u32, 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
| Type | Internal | Literal | Notes |
|---|---|---|---|
bool | bool | true/false | |
char | u32 | 'a' | UTF-32 codepoint, alias for u32 |
string | UTF-8 bytes | "hello" | codepoint-indexed (len/index/iterate count codepoints, not bytes); immutable — no index assignment |
any | — | — | compiler 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.
| Type | Zero value |
|---|---|
int, i32, etc | 0 |
float, f32 | 0.0 |
bool | false |
string | "" |
char | '\0' |
[T] | {} |
[K, V] | {} |
| struct | {} |
Token Reference
Operators
| Operator | Meaning |
|---|---|
: | 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
| Form | Type |
|---|---|
42 | int |
3.14 | float |
"hello" | string |
'a' | char |
true / false | bool |
{...} | 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).