Skip to content

0 · Zig for TypeScript developers — a noisy primer

You already know how to program — closures, generics, async, the works. What Zig asks you to learn is the layer underneath the runtime you’ve always had: where memory lives, who frees it, what a pointer is, and why there’s no garbage collector to save you. This primer teaches exactly that, mapped to TypeScript mental models, with the audio examples this course is built on. Targets Zig 0.16 on macOS (Apple Silicon); identical elsewhere.

Every Zig snippet here was compiled and run on 0.16. Type them out — zig run file.zig — the low-level stuff only clicks when you feel the compiler push back.

Coming from TypeScript, these are the things that will feel alien — and they’re the whole point of Zig:

  1. No garbage collector. In TS, you new things and forget them; V8 reclaims memory later. In Zig you decide where every heap byte comes from and you free it. Nothing is automatic.
  2. Values, not references. A TS object/array is a reference — pass it to a function and mutations leak back. A Zig struct is a value — passing it copies it. Sharing requires an explicit pointer.
  3. Nothing is hidden. No implicit number conversions, no hidden allocations, no exceptions thrown from anywhere. If something costs memory or can fail, you see it in the source.
  4. Errors are values, not control flow. No throw. A function that can fail returns !T and you handle it like a Result.
  5. comptime instead of metaprogramming. Generics, as const, and codegen collapse into one idea: ordinary code that runs at compile time.

Here’s the cross-reference you’ll keep coming back to:

TypeScriptZignote
number (always f64)u8 i16 u32 usize f32 f64 …sizes are explicit
let / constvar / constconst is the default reach
object passed by referencestruct passed by value (copied)use *T to share
T | null, x?.y, a ?? b?T, if (x) |y|, a orelse bno null floating around
throw / try/catch / finallyerror.X / !T, try/catch / defererrors are values
garbage collectorallocator + defer alloc.free(x)you own lifetimes
Array<T> (growable)[N]T (fixed) · []T (view) · std.ArrayList(T) (growable)three distinct things
generics <T>, as constcomptimeone mechanism
Atomics, SharedArrayBuffer, Workersstd.atomic.Value, std.Threadreal shared-memory threads
DataView / BufferwriteInt(.little), std.mem.asBytesraw bytes, your endianness

Native arm64 — nothing emulated.

  • Easiest: grab the aarch64-macos archive of 0.16.0 from https://ziglang.org/download/, unzip, put zig on your PATH.
  • Homebrew: brew install zig — but confirm zig version is 0.16 (0.16 changed the file-I/O API this course uses).
  • First launch may hit Gatekeeper (“unidentified developer”); allow it in System Settings → Privacy & Security or xattr -dr com.apple.quarantine /path/to/zig.
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, sound!\n", .{});
}
zig run hello.zig

@import/@-names are builtins (compiler intrinsics). std.debug.print takes a format string and a tuple of args .{ ... } — TypeScript would use template literals; Zig keeps the values in a separate tuple so there’s no string interpolation magic.

↳ Zig reference: Hello World, Builtin Functions.


In TypeScript every value you didn’t inline lives on the heap and the GC frees it “eventually.” Zig has two places:

  • The stack — local variables. Created on function entry, destroyed on return, automatically. Fast, scoped, no bookkeeping. (This is the only place TS gives you for free too, but you never think about it.)
  • The heap — anything whose size or lifetime isn’t known at compile time, or that must outlive the function. You request it from an allocator and you must give it back.
fn stackExample() void {
var samples: [256]f32 = undefined; // 256 floats, ON THE STACK
samples[0] = 0.5;
} // <- samples vanishes here, automatically. No free needed.

A 256-float buffer is fine on the stack; a 5-second 44.1 kHz buffer (220 500 floats ≈ 860 KB) is not — stacks are small (often 8 MB total), so big or dynamically-sized buffers go on the heap (Lab 8).

TS contrast. const samples = new Float32Array(256) always heap-allocates and is GC’d. In Zig the same-looking fixed array is a stack value that costs nothing to free. Choosing stack vs heap is a decision you now make on purpose.

↳ Zig reference: Memory, Choosing an Allocator, Variables.

This is the bug TS developers hit first. A TS object is a handle to heap memory; passing it shares it:

function bump(o: { phase: number }) { o.phase += 0.1; } // mutates the caller's object
const osc = { phase: 0.5 };
bump(osc); // osc.phase is now 0.6

A Zig struct is a value. Passing it copies it, and — surprise — function parameters are immutable. So this is a compile error:

// fn bump(o: Osc) void { o.phase += 0.1; } // ERROR: cannot assign to constant

To get TS-like behavior you pass a pointer explicitly; to copy, you copy explicitly:

const Osc = struct { phase: f32 = 0, inc: f32 = 0 };
fn bumpCopy(o: Osc) Osc { // o is an immutable copy
var local = o; // make a mutable copy
local.phase += 0.1;
return local; // hand a new value back
}
fn bumpInPlace(o: *Osc) void {
o.phase += 0.1; // o.phase is sugar for o.*.phase — mutate the original
}
pub fn main() void {
var osc: Osc = .{ .phase = 0.5 };
const copied = bumpCopy(osc); // osc untouched (0.5)
bumpInPlace(&osc); // osc now 0.6
std.debug.print("copied={d:.2} osc={d:.2}\n", .{ copied.phase, osc.phase });
}

Why this matters for audio. Oscillators, filters, and envelopes are structs with state. If you accidentally take them by value in your per-sample loop, each call mutates a throwaway copy and your phase never advances — a silent, confusing bug. Take *Osc when you mean to advance it.

↳ Zig reference: Pass-by-value Parameters, struct.

3 · Pointers, for people who’ve never held one

Section titled “3 · Pointers, for people who’ve never held one”

TypeScript has references but no pointers — you can’t take the address of a thing or talk about “the memory at this location.” Zig has a small, safe family:

var x: i32 = 42;
const p: *i32 = &x; // &x = "address of x", type *i32 (pointer to one i32)
p.* = 7; // p.* = "the value p points at" — read or write it
// x is now 7
  • &value — take its address (like nothing in TS).
  • *T — “pointer to one T”. p.* dereferences it.
  • o.field on a pointer auto-derefs: o.phase == o.*.phase.

There are richer pointer shapes you’ll meet in the course, all distinct on purpose:

var buf = [_]f32{ 1, 2, 3, 4 };
const whole: *[4]f32 = &buf; // pointer to the WHOLE array (length known: 4)
const view: []f32 = &buf; // SLICE: a fat pointer = address + length
const many: [*]f32 = &buf; // MANY-item pointer: address only, NO length
// view.len == 4 ; many has no .len, you must track length yourself

The one you’ll use constantly is the slice []T — a pointer and a length travelling together. It’s the closest thing to a JS array view, and it’s bounds-checked in safe builds.

No pointer arithmetic on *T. In C you’d do p + 1; Zig forbids that on single-item pointers. You index a slice (view[i]) or a many-item pointer (many[i]) instead — the intent (“this points at many things”) is in the type.

↳ Zig reference: Pointers, Slices.

TypeScript bolts null/undefined onto every type and hopes you check. Zig makes “might be absent” a distinct type, ?T, and the compiler won’t let you use it without unwrapping:

// TypeScript
let osc: Osc | null = null;
osc?.phase; // optional chaining
const f = osc?.phase ?? 440; // nullish coalescing
var osc: ?*Osc = null; // optional pointer: either an address or null
if (osc == null) { /* ... */ }
if (osc) |o| { // unwrap: inside the block, o is a real *Osc
std.debug.print("phase {d}\n", .{o.phase});
}
const f = (osc orelse defaultOsc).phase; // `orelse` is exactly TS `??`

if (x) |v| { ... } is the workhorse — it both tests for null and binds the unwrapped value v. orelse supplies a fallback. There’s no way to “accidentally deref null”: a ?T simply doesn’t have the fields of T until you unwrap it. (Pointers themselves are never null unless you wrote ?*T.)

↳ Zig reference: Optionals, Optional Pointers, null.

TS has one numeric type (number, an f64). Zig has many, and the width is part of the type: u8 u16 u32 u64 usize (unsigned), i8 … i64 isize (signed), f32 f64. Audio uses them deliberately — i16 samples, usize indices, f32 for DSP math.

Because widths differ, Zig never converts between number types implicitly. You convert with builtins, so every rounding/truncation is visible:

const i: usize = 16384;
const f: f32 = @as(f32, @floatFromInt(i)) / 32768.0; // int → float
const s: i16 = @intFromFloat(f * 32767.0); // float → int (truncates)
const b: u8 = @truncate(@as(u32, 300)); // narrow, dropping high bits

And overflow is a choice, not silent wraparound or Infinity:

const x: u8 = 250;
// const bad = x + 10; // PANICS in safe builds (260 > 255)
const wrap = x +% 10; // +% = explicit wrapping → 4
const ov = @addWithOverflow(x, 10); // returns .{ value, overflow_bit }
// ov[1] == 1 means it overflowed

Why audio cares. A 16-bit sample must land in i16; a bytebeat track wants u8 wraparound (*%) as its sound. In TS you’d never notice 8-bit overflow because there are no 8-bit numbers. Here it’s both a safety feature and an instrument.

↳ Zig reference: Integers, Floats, @addWithOverflow, Type Coercion.


Three things TypeScript blurs into Array<T>:

const fixed = [_]f32{ 261.63, 329.63, 392.00 }; // [3]f32 — fixed size, a VALUE
const view: []const f32 = &fixed; // slice — a VIEW (ptr+len), no copy
const part = fixed[1..]; // sub-slice from index 1 to end
const zeros = [_]u8{0} ** 4; // {0,0,0,0} — `**` repeats (your calloc)

For a genuinely growable list (TS push), use std.ArrayList(T) — but it needs an allocator (next), because growing means heap reallocation, which Zig will not do behind your back.

x.len is the length, x[i] indexes (bounds-checked in Debug/ReleaseSafe), x[a..b] slices. We use sub-slices to envelope one note inside a big buffer without copying.

↳ Zig reference: Arrays, Slices.

// while with a "continue expression" = C's for(;; i++)
var i: usize = 0;
var sum: f32 = 0;
const xs = [_]f32{ 0.1, 0.2, 0.3 };
while (i < xs.len) : (i += 1) sum += xs[i];
// for that captures BY POINTER (|*s|) to write, plus an index via `0..`
var ramp: [4]f32 = undefined; // `undefined` = "I'll fill it"
for (&ramp, 0..) |*s, k| s.* = @as(f32, @floatFromInt(k)) / 4.0;
// multi-sequence for — walk several slices in lockstep (a 50/50 mix)
const a = [_]f32{ 0.1, 0.2, 0.3 };
const b = [_]f32{ 0.3, 0.2, 0.1 };
var mixed: [3]f32 = undefined;
for (&mixed, a, b) |*o, p, q| o.* = (p + q) * 0.5;

&xs iterates by pointer so s.* writes each slot; multi-sequence for requires equal lengths (checked). undefined is intentional uninitialized memory — a promise to fill it, not TS’s undefined value.

if is an expression (returns a value), and switch must be exhaustive — miss a case and it won’t compile:

const Wave = enum { sine, square, saw }; // like a TS string-union, but a real type
fn shape(w: Wave, phase: f32) f32 {
return switch (w) {
.sine => std.math.sin(2.0 * std.math.pi * phase),
.square => if (phase < 0.5) -1.0 else 1.0,
.saw => 2.0 * phase - 1.0,
};
}

TS contrast. A switch on a string union in TS only catches missing cases if you wire up never checks. Zig’s exhaustive switch is built in — add a .triangle variant and every switch that forgot it fails to compile. Gold for state machines (see the ADSR envelope).

↳ Zig reference: while, for, if, switch & Exhaustive Switching, enum, undefined.

const Osc = struct {
phase: f32 = 0.0, // default field values
inc: f32 = 0.0,
fn setFreq(self: *Osc, freq: f32, sr: f32) void {
self.inc = freq / sr;
}
fn next(self: *Osc) f32 {
const v = std.math.sin(2.0 * std.math.pi * self.phase);
self.phase += self.inc;
if (self.phase >= 1.0) self.phase -= 1.0;
return v;
}
};
var osc: Osc = .{}; // .{} = "construct with defaults" (no `new`)
osc.setFreq(440.0, 44100.0);
const s = osc.next();

Differences from a TS class: there’s no this keyword — the receiver is an explicit first parameter self: *Osc (and it’s a pointer when you mutate). There’s no constructor magic — .{} is just a struct literal using defaults, and you can write your own init function by convention. No inheritance; Zig favors composition and comptime.

↳ Zig reference: struct, Default Field Values, Anonymous Struct Literals.

A function that can fail returns !T — “T or an error”. It’s TypeScript’s Result<T, E> baked into the language, with terse handling:

fn half(x: f32) !f32 {
if (x < 0) return error.Negative; // an error is just a value
return x / 2.0;
}
pub fn main() !void { // main can propagate errors too
const a = try half(0.8); // `try` = unwrap or return the error upward
const b = half(-1.0) catch 0.0; // `catch` = provide a fallback value
_ = .{ a, b };
}

Mapping from TS: throwreturn error.X; try { } catch (e) { }x catch |e| { }; rethrow/propagate → try x. The difference: the ! in the return type makes failure part of the signature — you can’t forget a function might fail, because you can’t call it without try/catch.

↳ Zig reference: Errors, Error Union Type, try, catch.

defer schedules cleanup to run when the scope exits — any exit. It’s finally, but you write it right next to the thing it cleans up:

fn loadSamples(alloc: std.mem.Allocator) ![]f32 {
const buf = try alloc.alloc(f32, 1024);
errdefer alloc.free(buf); // free ONLY if a later step errors
try fillFromDisk(buf); // if this fails, errdefer fires, buf is freed
return buf; // success: caller now owns buf (errdefer does NOT fire)
}

errdefer is the half TS has no equivalent for: run cleanup only on the error path. It’s how you avoid leaking a half-built resource when initialization fails partway.

↳ Zig reference: defer, errdefer.

Here’s the heart of it. TS hands you a GC; Zig hands you an allocator interface (std.mem.Allocator) and several implementations to choose per use-case. Anything needing heap memory takes an alloc parameter — so a function’s signature tells you whether it allocates.

const std = @import("std");
fn makeBuffer(alloc: std.mem.Allocator, n: usize) ![]f32 {
const buf = try alloc.alloc(f32, n); // ask for n floats on the heap
@memset(buf, 0.0); // zero them (no startup noise)
return buf; // caller owns this now
}
pub fn main() !void {
var dbg: std.heap.DebugAllocator(.{}) = .init;
defer _ = dbg.deinit(); // at program end, reports any LEAK you forgot to free
const alloc = dbg.allocator();
const buf = try makeBuffer(alloc, 220_500); // 5 s @ 44.1k — heap, not stack
defer alloc.free(buf); // pair every alloc with a free
buf[0] = 0.5;
std.debug.print("{d} samples, buf[0]={d}\n", .{ buf.len, buf[0] });
}

The allocators you’ll actually pick between:

  • DebugAllocator — slow, but tracks every allocation and tattles on leaks and double-frees at deinit. Use it while learning/testing.
  • std.heap.page_allocator — straight from the OS; coarse, simple.
  • std.heap.ArenaAllocator — allocate freely, free everything at once. Perfect for “build a wavetable, use it, drop it” — no per-object frees:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // frees ALL of the below in one shot
const a = arena.allocator();
const t1 = try a.alloc(f32, 4096); // no individual free needed
const t2 = try a.alloc(f32, 4096);
_ = .{ t1, t2 };
  • std.heap.smp_allocator — fast general-purpose allocator for release builds.

The ownership rule of thumb. Whoever creates it frees it, or hands ownership to a caller (who then frees it). Write the defer free the instant you write the alloc, and the two never drift apart. This convention — not a GC — is what keeps Zig programs leak-free.

↳ Zig reference: Memory, Choosing an Allocator; std docs: std.mem.Allocator, std.heap.

12 · comptime (generics, as const, and macros — unified)

Section titled “12 · comptime (generics, as const, and macros — unified)”

In TS you reach for three different tools — generics for type parameters, as const for compile-time literals, decorators/codegen for metaprogramming. Zig has one: code marked comptime runs during compilation.

// a compile-time constant: dbGain(-6.0) is computed by the compiler, baked into the binary
fn dbGain(comptime db: f32) f32 {
return std.math.pow(f32, 10.0, db / 20.0);
}
// "generics" are just functions that take a type as a comptime argument:
fn Buffer(comptime T: type, comptime n: usize) type {
return [n]T; // returns a TYPE
}
const StereoFrame = Buffer(f32, 2); // == [2]f32, decided at compile time

Types are first-class at compile time: std.ArrayList(f32) is literally a function call returning a type, the way TS Array<number> is a generic instantiation — except it’s the same mechanism that does constant-folding and conditional compilation. You won’t write much comptime early on, but knowing “a generic is a comptime function over types” demystifies the standard library.

↳ Zig reference: comptime, Generic Data Structures, Compile-Time Parameters.


13 · Writing a WAV (raw bytes, the I/O interface)

Section titled “13 · Writing a WAV (raw bytes, the I/O interface)”

This is where the low-level view pays off: a .wav file is a 44-byte header plus raw 16-bit samples, and you write each field at a chosen byte order. TS would reach for DataView/Buffer; Zig’s writeInt(T, value, .little) is the same idea — you control the exact bytes. Zig 0.16 makes I/O an explicit io value you thread through. Full 1-second 440 Hz tone — zig run beep.zig, then open beep.wav:

const std = @import("std");
const sr: u32 = 44100;
fn writeHeader(w: *std.Io.Writer, data_len: u32) !void {
try w.writeAll("RIFF"); // ASCII tag, endian-less
try w.writeInt(u32, 36 + data_len, .little);
try w.writeAll("WAVE");
try w.writeAll("fmt ");
try w.writeInt(u32, 16, .little); // fmt chunk size
try w.writeInt(u16, 1, .little); // PCM
try w.writeInt(u16, 1, .little); // mono
try w.writeInt(u32, sr, .little);
try w.writeInt(u32, sr * 2, .little); // byte rate
try w.writeInt(u16, 2, .little); // block align
try w.writeInt(u16, 16, .little); // bits/sample
try w.writeAll("data");
try w.writeInt(u32, data_len, .little);
}
pub fn main() !void {
var dbg: std.heap.DebugAllocator(.{}) = .init;
defer _ = dbg.deinit();
var threaded: std.Io.Threaded = .init(dbg.allocator(), .{});
defer threaded.deinit();
const io = threaded.io();
var file = try std.Io.Dir.cwd().createFile(io, "beep.wav", .{});
defer file.close(io);
var wbuf: [4096]u8 = undefined; // the writer's buffer (stack)
var fw = file.writer(io, &wbuf);
const w = &fw.interface; // a *std.Io.Writer
const n: u32 = sr; // one second
try writeHeader(w, n * 2);
var i: u32 = 0;
while (i < n) : (i += 1) {
const t = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(sr));
const s = std.math.sin(2.0 * std.math.pi * 440.0 * t) * 0.3; // 0.3 = headroom
try w.writeInt(i16, @intFromFloat(s * 32767.0), .little);
}
try w.flush(); // ← buffered bytes reach disk only here. Forgetting = empty file.
}

Turn the volume down before playing. The buffered-writer + flush() pattern is the one thing TS hides (a WriteStream flushes itself); here it’s explicit, and forgetting flush() is the classic first bug. asBytes/endianness become real because there’s no runtime serializing for you.

↳ Zig reference: @floatFromInt, @intFromFloat, @as; std docs: std.Io, std.mem.asBytes.

Bytebeat — wrapping math as an instrument. Unsigned overflow is opt-in (*%, +%); that wraparound is the chiptune sound:

var t: usize = 0;
while (t < 8000) : (t += 1) {
const x: u8 = @truncate(t *% 5 & t >> 7 | t *% 3 & t >> 10);
// write x as one byte of 8-bit audio
}

Threads & atomics — and yes, this maps to the web. Zig threads share memory directly; std.atomic.Value is the same concept as JS Atomics over a SharedArrayBuffer between Web Workers — just without the structured-clone wall:

const std = @import("std");
var counter = std.atomic.Value(u32).init(0);
fn bump() void {
var i: u32 = 0;
while (i < 1000) : (i += 1) _ = counter.fetchAdd(1, .monotonic);
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, bump, .{});
const t2 = try std.Thread.spawn(.{}, bump, .{});
t1.join();
t2.join();
std.debug.print("counter = {d}\n", .{counter.load(.monotonic)}); // exactly 2000
}

That .monotonic is a memory-ordering choice TS never exposes; the ctrl chapter is the deep dive (real-time audio threads, lock-free ring buffers).

↳ Zig reference: Atomics, @atomicRmw, @atomicStore; std docs: std.Thread, std.atomic.Value.

  • Run: zig run beep.zig (compiles to a temp binary and runs it).
  • Fast binary: zig build-exe beep.zig -O ReleaseFast./beep. Swap DebugAllocator for std.heap.smp_allocator in release.
  • Safety modes: Debug & ReleaseSafe keep bounds checks, overflow checks, and undefined poisoning; ReleaseFast/ReleaseSmall drop them for speed. (TS has no equivalent — you’re always in “safe.”)
  • A real project: zig initbuild.zig + src/main.zig, then zig build run. Not needed here; single files keep the focus on sound.
You want…TypeScriptZig
immutable / mutable bindingconst / letconst / var
share & mutate an objectpass object (by ref)pass *T, mutate o.field
copy a struct{...obj}var c = obj; (assignment copies)
maybe-absent valueT | null, ?., ???T, if (x) |v|, orelse
address of / dereference&x / p.*
fixed buffer / view / growableArray<T>[N]T / []T / std.ArrayList(T)
int↔float, narrowimplicit@floatFromInt,@intFromFloat,@truncate,@as
wraparound math(n/a — all f64)+%, *%, @addWithOverflow
throw / catch / finallythrow/try-catch/finallyerror.X & !T / catch / defer,errdefer
free memoryGC (automatic)alloc.alloc + defer alloc.free
temporary scratch memoryGCArenaAllocator (free all at once)
generics / compile-time const<T>, as constcomptime (functions over types)
shared-memory concurrencyWorkers + Atomicsstd.Thread + std.atomic.Value
raw bytes / endiannessDataViewwriteInt(.., .little), std.mem.asBytes
exhaustive switchnever trickbuilt-in exhaustive switch

Everything above compiled and ran on Zig 0.16 (Apple Silicon).

Each section above links to the exact concept; here are the hubs:

The Language Reference is pinned to 0.16.0 so the links match the code here. For the very latest, swap 0.16.0master in any URL.

You’ve now seen, in miniature, the whole course: stack/heap and the WAV writer (wave), the oscillator struct and pointers (osc i), enums/switch, slices and allocators for wavetables (osc ii), f32 math and mixing (mix), the envelope state machine (adsr), the ring buffer (delay), and atomics/threads/memory-ordering (ctrl).

The GC is gone, but so is the mystery. Go make some noise. → 2 · wave

Want more Zig before the DSP? Part 2 (chapter 1) goes deeper into the type system — packed structs, tagged unions, error sets, iterators, comptime tables, and the test runner — using MIDI, music theory, and a Euclidean sequencer as the playground.