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 (i8u32, 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 nilnil 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 .zn library module in the Standard Library reference.
  • Write a native extension against the frozen ABI in Extensions & ABI.
On this page