Overview
A guided tour of Zinc, top to bottom. If you already know the shape of the language and just need exact rules, see the Language Spec instead — this page is meant to be read start to finish.
Introduction
Zinc is a small, garbage-collected, dynamically-typed scripting language with a bytecode virtual machine and a gradual static type checker layered on top. Its identity is deliberately narrow: Zinc is a scripting / orchestration ("glue") language. You write the hot, systems-level, or performance-critical parts of an application as native extensions — plain Odin (or C) compiled to a shared library — and use Zinc to wire them together, script behavior, and iterate quickly without a recompile. It is not trying to be a systems language, and it is not trying to be fast at tight numeric loops; it is trying to be pleasant to write, predictable to read, and easy to embed.
Zinc is also a solo, opinionated pet project, not something built by a team or a company. Its design choices reflect one person's taste rather than a design-by-committee process.
A Zinc program is a plain text file, conventionally named with a
.zn extension, run directly by the interpreter:
// hello.zn
println("Hello, world!")
$ zinc.exe hello.zn
Hello, world!
There's no project file, no build step, and no explicit main
function — a script's top-level statements run in order, the same way a
shell script or a Python file does. The standard library is embedded
directly in the interpreter binary, so a bare zinc.exe can run
any script with no accompanying stdlib/ directory.
A few guiding principles shape everything below:
- Minimal keyword surface. If the grammar can resolve something structurally, it doesn't burn a keyword on it.
- Explicit over implicit. Casts, visibility, and types are never magic — there is no implicit numeric promotion, and no hidden conversions.
- Zero values are always valid. Every type — including every struct — has a well-defined zero value, so "half-initialized" states don't exist.
- Three collection types, not one pretending to be three.
string,[T](list), and[K, V](map) are genuinely distinct types unified by shared behavior (iteration, indexing,len) rather than one being desugared into another.
Comments
// a line comment, runs to end of line
/* a block comment,
can span multiple lines */
Variables & Constants
Every binding in Zinc is declared with one grammar:
name : [Type] = value for a mutable binding, or
name : [Type] : value for an immutable one. The type annotation
is always optional — when you omit it, Zinc infers it from the
right-hand side. := and :: are shorthand for the
inferred forms, and in practice they're what you'll write almost all of the
time:
x: int = 42 // mutable, annotated
y := 3.14 // mutable, inferred
PI: float : 3.14 // immutable, annotated
MAX :: 100 // immutable, inferred (the common case for constants)
Reassigning an immutable (::) binding is a compile-time error,
not a runtime one — Zinc's type checker catches it before the script ever
runs. Naming has meaning too: a name starting with an underscore
(_secret) is private to its module or struct, and a bare
underscore (_) is a write-only discard, useful for ignoring a
return value: _, err := might_fail().
Basic Types
Zinc has the numeric tower you'd expect from a systems-adjacent language,
but with one important simplification: ordinary arithmetic always happens at
64-bit width. int is an alias for i64,
float is an alias for f64, and that's what you'll
use for almost everything. The narrower types (i8…u32,
f32) exist for storage and interop with native extensions —
a value is only ever truncated to a narrower width by an
explicit cast, never silently:
z := int(3.14) // explicit cast, z = 3
small := i32(z) // truncates to 32 bits — but only because you asked
There is no implicit promotion between int and
float either — mixing them in arithmetic or comparison is a
runtime error. This is a deliberate trade for predictability: if code
compiles and runs, you know exactly what width and kind of number you're
holding.
Rounding out the primitives: bool (true/
false), char (a UTF-32 codepoint, written
'a'), and nil — a real, first-class value discussed
in full under Optionals & Unions.
Every type has a well-defined zero value (0, 0.0,
false, "", an empty collection, a zero-valued
struct), and critically, none of those zero values is
nil — nil only ever lives in a slot whose
type explicitly includes it.
Collections
Zinc has three collection types — string, [T]
(list), and [K, V] (map) — and treats them as genuinely distinct
rather than layering two of them on top of the third. What they share is a
small set of protocols: you can iterate any of them with
for, index into any of them, and ask any of them their
len. What differs is honest and type-specific.
Strings
Strings are immutable and UTF-8 under the hood, but conceptually you work
with them as a sequence of Unicode codepoints, not bytes:
len, indexing, and slicing all operate per-codepoint, so
len("café") is 4, not 5, even though "é" takes two
bytes to encode.
name := "hello, world"
first := name[0] // 'h', a char
piece := name[0:5] // "hello", a new string (slicing is codepoint-based)
There's no index-assignment on strings — build new ones with
concatenation, slicing, or the strbuilder module for anything
performance-sensitive.
Lists
[T] is an ordered, integer-indexed, growable sequence.
amts: [int] = {10, 20, 30}
amts[0] = 99 // index assignment updates an existing slot
append(amts, 40) // append is the only way to grow
println(contains(amts, 99)) // value search: true
Lists are reference types — assigning a list to another
variable aliases the same underlying data, it doesn't copy it. There is no
built-in deep-copy helper yet; build an independent copy manually (e.g.
for x in xs { append(ys, x) }).
Maps
[K, V] is an explicitly-keyed hash map (valid key types:
int, string, bool, and enum values),
backed by a real hash table with O(1) get/set/has/delete, and it preserves
insertion order when you iterate it.
stats: [string, int] = {
"strength": 10,
"intelligence": 12,
}
stats["strength"] = 9
println(has(stats, "luck")) // false — key presence
println(stats["luck"]) // nil — reading an absent key never errors
Reading a key that isn't present returns nil rather than the
value type's zero, specifically so "missing" and "present but zero" stay
distinguishable. keys(m), values(m), and
delete(m, key) round out the map API.
Literal disambiguation
The same brace syntax {...} builds lists, maps, and
zero values, disambiguated by shape: bare values ({10, 20, 30})
are a list; colon-pairs ({"foo": 1}) are a map; and an empty
{} resolves to the zero value of whatever type context expects
— 0 for int, an empty list for [int],
a zero-valued instance for a struct type; a typed declaration, a
return against a declared return type, and a struct-literal
field value all count as type context. (A bare {} with
no resolvable type context, like a top-level x := {} or
a plain assignment RHS, is a compile error — "cannot infer type of
{}". Use Type{} or a typed declaration when you
want a zero value of a specific type there.)
Operators & Casting
sum := x + y
div, mod := x / 3, x % 5
Casting uses the type name as a one-argument call — there's no separate
cast syntax to learn, and this is why a primitive type name like
int or string can never be the name of a
user-defined function:
z := int(3.14) // 3
n := f32(some_i32)
Comparison operators (< > <= >=) work on two
ints, two floats, two chars, or two strings (lexicographically) — comparing
across those kinds, or comparing anything else, is a runtime error. There
are no bitwise operators. Logical && / ||
short-circuit and evaluate to whichever operand decided the result
(Python/Lua-style — 1 || 2 is the int 1, not
true), while their compound-assignment cousins &=
/ |= are eager (no short-circuit) and always produce a real
bool.
Control Flow
if / elseif / else
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 — each arm's label is compared with ==, and an
optional else covers everything else:
classify :: (n: int) => string {
match n {
0 => { return "zero" }
1 => { return "one" }
else => { return "many" }
}
return "unreachable"
}
If nothing matches and there's no else, match
simply does nothing and falls through, rather than erroring.
Loops
There's one loop keyword, for, in three shapes: conditional,
iteration, and (via the stdlib's range) counted.
// conditional
i := 0
for i < 5 {
i = i + 1
}
// iteration — value first, index/key optional
for value in amts { }
for index, value in amts { }
for key, value in stats { }
// counted, via the range() builtin
for n in range(0, 10) { }
for n in range(0, 10, 2) { } // with a step
break and continue work exactly as you'd
expect. There is deliberately no in expression operator for
membership testing — that's what the has(collection, key) and
contains(collection, value) builtins are for.
Functions
Functions are first-class values, declared with the same
::/:= binding grammar as everything else, and the
return type is separated by =>. That arrow is required on
every function, even ones that return nothing — there's no
ambiguity to resolve about whether a trailing block is a function body or
something else.
add :: (a: int, b: int) => int {
return a + b
}
greet :: () => { // no return type, but => is still required
println("Hello, world!")
}
Multiple return values are a first-class part of a function's type — parentheses are always required around a multi-value return type:
divmod :: (a: int, b: int) => (int, int) {
return a / b, a % b
}
q, r := divmod(17, 5)
Variadic parameters (must be the last parameter) desugar to a list inside
the function body, but the caller must pass arguments variadically —
handing a list straight to a variadic parameter is a compile error. The
.. spread operator is the sanctioned bridge between the two:
sum_all :: (args: ..int) => int {
total := 0
for v in args { total = total + v }
return total
}
sum_all(1, 2, 3) // fine
nums := {1, 2, 3}
sum_all(..nums) // spread a list into individual arguments
Closures
Inner functions capture variables from enclosing scopes by reference; the VM heap-allocates a captured variable automatically once it's known to escape its declaring scope, so this works exactly the way you'd hope:
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 — on a normal fall-through, a return, a
break, or a continue. Multiple defers
in the same scope unwind in LIFO order, Odin-style:
lifo :: () => {
defer println("A")
defer println("B")
defer println("C")
println("body")
}
lifo()
// body, C, B, A — reverse declaration order
One difference from Odin's own defer is worth knowing up
front: Zinc re-evaluates the deferred expression at the moment each exit
path fires, against whatever local state exists then, rather than
snapshotting it at the defer statement itself. A
defer that reads a variable mutated later in the same scope will
see the mutated value when it actually runs.
Structs
Structs are always passed by reference — there is no copy-on-assignment
for struct values, the same as lists and maps. A struct's zero value is
always a valid, usable instance; a new-style static
constructor is a convenience, never a requirement.
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's type names the struct, so this is a method
level_up :: (entity: Entity) => {
for stat, value in entity.stats {
entity.stats[stat] = value + 1
}
}
}
ent := Entity.new(1, "Goblin")
ent->level_up()
There's no self keyword — Zinc decides whether a struct
member function is a method or a static
purely structurally: if its first parameter's type annotation names the
enclosing struct (the parameter can be called anything —
entity, self, e, only the
type matters), it's a method, callable either as
Entity.level_up(ent) or the shorthand
ent->level_up(). Otherwise it's a static, called directly on the
type: Entity.new(...).
There's no inheritance and no embedding sugar — compose structs explicitly
(orc.base.name) instead.
Enums
Direction :: enum {
North, East, South, West,
}
d: Direction = Direction.North
d2: Direction = .South // shorthand — type is known from the annotation
When the target type is already known from context — a variable annotation, a parameter type, a return type — a variant can be written with just a leading dot. Enum values are also valid map keys.
Interfaces
Interfaces are satisfied implicitly — any struct whose methods match an interface's declared shapes satisfies it automatically, with no explicit "implements" declaration anywhere:
Drawable :: interface {
draw: (Drawable)
bounds: (Drawable) => (int, int)
}
Entity :: struct {
draw :: (self: Entity) => { /* ... */ }
bounds :: (self: Entity) => (int, int) { return 10, 20 }
}
// Entity implicitly satisfies Drawable — no declaration needed
drawables: [Drawable] = {ent}
A stdlib Iterator interface — a single next method
returning (any, bool), the current value and whether to keep
going — is planned but not yet implemented. for x in
val only works today on the built-in iterable kinds (list, map,
string); a struct satisfying Iterator does NOT make it work —
it's a runtime error ("value is not iterable").
Error Handling
Zinc has no dedicated error type, no exceptions, and no try/
catch. Recoverable failure is handled with ordinary multi-return
— by convention, either a trailing bool or an optional error
value:
find_entity :: (id: int) => (Entity, bool) {
return some_entity, true // found
return {}, false // not found
}
result, ok := find_entity(1)
if !ok {
return {}, false
}
The standard library follows one specific shape of this convention
throughout — see the stdlib reference for the
exact (value, err) rule fallible functions follow. Separately,
panic(msg?) and assert(cond, msg?) exist for
genuinely unrecoverable situations — "this should never happen" — and abort
loudly with a stack trace rather than returning a value at all.
Optionals & Unions
Zinc's static checker gives you real tagged union types, T | U,
with T? as sugar for T | nil. A variable still has
exactly one fixed static type (reassigning to an incompatible concrete type
is a compile error) — a union type simply is that fixed type, wide
enough to hold any of its listed members:
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 plain int inside this branch
}
return -1
}
nil is a real, first-class, writable value — not a special
"no value" marker bolted on from outside the type system — but it only
type-checks against a union or optional slot that explicitly lists it as a
member. It is never a concrete type's zero value. Inside an
if x != nil { ... } branch, a union value automatically narrows
to its checked member, so you can use it as that concrete type with no
explicit cast.
Generics
Functions can declare type parameters, inferred entirely from call-site argument types — no explicit type arguments to write anywhere:
id :: (x: T) => T { return x }
println(id(42)) // T = int
println(id("hello")) // T = string
head :: (xs: [T]) => T { return xs[0] }
nums: [int] = {10, 20, 30}
println(head(nums)) // T = int
Every call — generic or not — is argument-type checked, and type
parameters erase completely at runtime; there's no reified generic type
hanging around. Generic structs aren't implemented yet — today,
queue and result (whose payload lives in a struct
field) stay any-typed and expose generic constructor
functions as a stand-in; algo is already fully migrated
to real generic functions, and set never needed any
to begin with.
Modules & Imports
import "strings" // binds as `strings`
import "math" as m // binds as `m`
import "models/entity" // binds as `entity`
Imports are always whole-namespace — there's no selective/named import syntax, you always get the full module. A module executes exactly once on its first import; every subsequent import of the same path returns the already-executed, cached namespace, so module-level state is genuinely shared across every importer. Circular imports are a runtime error (not a compile-time one), and import order within a file doesn't matter — everything resolves before the script body runs.
Beyond .zn modules, Zinc also loads native
.dll/.so/.dylib extensions through the
same import syntax — see
Extensions & ABI if you need to wrap
native code.
Next Steps
- Look up exact grammar, operator precedence, and the full literal/token reference in the Language Spec.
- Browse every builtin, prelude function, native module, and
.znlibrary module in the Standard Library reference. - Write a native extension against the frozen ABI in Extensions & ABI.