A tour of the Silicon language, in the spirit of the Go Tour and the Odin Overview. It moves from "hello world" through types, control flow, data, generics, error handling, memory, the standard library, platforms, and Silicon's metaprogramming system, strata.
Silicon compiles to WebAssembly (and, via QBE, to native binaries). Everything
below runs with the sgl compiler — install it from
Getting Started.
Convention: a value binding is bare (
name := value, immutable);@mutmarks a mutable binding,@fna function, and@typea type. Calls are parenthesised (print(x)), including built-ins like@if(…)/@loop(…). A\\ name (Types) -> Retline above a definition is its signature line.
@use 'io';
@fn main := {
print('Hello, Silicon!');
0
};
main();
sgl run hello.si # Hello, Silicon!@use 'io' pulls in the standard-library I/O module. print writes a line to
stdout. The top-level main(); is the program's entry point.
The toolchain:
sgl init my-project # scaffold sgl.toml + src/main.si
sgl run # compile to wasm + execute (wasmtime)
sgl build # produce a .wasm
sgl build --native # produce a native binary (via QBE)
sgl check # type-check only, with caret diagnostics# Line comments start with '#'.
## Doc comments start with '##' and attach to the following definition.
| Type | Description | Literals |
|---|---|---|
Int |
target-sized signed integer (i32 on wasm32) |
0, 42, 0 - 7 |
Int64 |
64-bit signed integer | @toInt64(42) |
Float |
32-bit float (f32) |
3.14, 0.0 |
Bool |
boolean | @true, @false |
String |
length-prefixed UTF-8 | 'hello' |
Conversions are explicit — there is no implicit numeric coercion:
n := @toInt(3.75); # Float -> Int (truncates) = 3
f := @toFloat(10); # Int -> Float = 10.0
big := @toInt64(1); # Int -> Int64
Silicon has two value bindings, distinguished by mutability:
PI := 3.14159; # immutable — a module constant (cannot be reassigned)
@mut count := 0; # mutable
count = count + 1; # reassign — only @mut can be reassigned
A bare name := value is an immutable binding — a module constant at the top
level, an immutable local inside a function. @mut name := value is mutable (a
module variable at the top level, a mutable local inside a function).
Binding types are always inferred — there is no type annotation on a
variable. To pin types, annotate the function with a \\ signature line:
\\ area (Int, Int) -> Int
@fn area w, h := { w * h };
+ - * / % # arithmetic (Int and Float)
== != < <= > >= # comparison -> Bool
|| # logical OR
++ # string concatenation
Operators dispatch on operand type: + on two Floats emits f32.add, on two
Ints i32.add. Both operands must be the same type. Logical AND is written as
nested @if (there is no && operator).
msg := 'foo' ++ '-' ++ int_to_str(99); # "foo-99"
@if is an expression-or-statement: @if(cond, { then }, { else }).
@if(x == 0, { print('zero') }, { print('nonzero') });
@loop is the one loop keyword; it dispatches on the number of operands before
the { body } block — a bare block loops forever, one operand is a while
condition, and two or three iterate over a lo..hi range or a Vec (@break()
exits early):
@mut i := 0;
@loop(i < 10, { print_int(i); i = i + 1 }); # while
@loop(v, 0..10, { print_int(v) }); # half-open range (10 excluded)
@loop(i, v, xs, { … }); # Vec: index + element
See Conditions & loops and ADR 0016 for every form and the v1 scope.
@return returns early from a function:
\\ safe_div (Int, Int) -> Int
@fn safe_div x, y := {
@if(y == 0, { @return(0) }, {});
x / y
};
@match destructures sum types (see §9).
\\ add (Int, Int) -> Int
@fn add a, b := { a + b };
s := add(2, 3); # call with parens, args comma-separated
A single-expression body needs no braces:
\\ square (Int) -> Int
@fn square n := n * n;
Export functions to the host with @export, and import host functions with
@extern:
@export add;
\\ @extern puts (String) -> Int;
puts('from C');
A top-level @fn can be taken as a value with @fnref and called indirectly
with @call_indirect:
\\ apply (Int, Int) -> Int
@fn apply f, x := { @call_indirect(f, x) }; # f is a function-table index
apply(@fnref(square), 7); # 49
A closure captures surrounding values by value. @closure(body_fn, …caps)
binds the leading parameters of body_fn to the captures; @call_closure
invokes it with the remaining arguments:
\\ scale (Int, Int) -> Int
@fn scale factor, x := { factor * x }; # leading param `factor` = the capture
\\ make_scaler (Int) -> Int
@fn make_scaler factor := {
s := @closure(scale, factor); # capture `factor` by value
@call_closure(s, 5) # = scale(factor, 5)
};
Closures can be passed to higher-order Silicon functions, and — wrapped in
@export_callback — handed to the host as a callback it stores and invokes
later, with the captured environment intact (the basis for events like
addEventListener/setTimeout). Under --target=wasm-gc the closure
environment is engine-garbage-collected. Capture is by-value/immutable in v1.0.
@type Point := { x Int, y Int };
@fn main := {
p := Point(3, 4); # construct
print_int(p.x + p.y); # field access -> 7
0
};
A sum type has variants, each marked with $ and an optional payload:
@type Shape := $Circle r Int | $Square s Int;
\\ size (Shape) -> Int
@fn size sh := {
@match(sh,
$Circle r, { r },
$Square s, { s })
};
size(Circle(10)); # constructors are Circle / Square
An enum is a set of payload-free variants:
@type Color := $Red | $Green | $Blue;
\\ code (Color) -> Int
@fn code c := {
@match(c,
$Red, { 1 },
$Green, { 2 },
$Blue, { 3 })
};
@match is an ordinary call: each arm is a pattern argument followed by a
{ … } block body. Pattern alternation shares a body across variants:
$Red | $Green, { 1 }.
Silicon uses HM-lite — Hindley–Milner inference restricted to declared
polymorphism (@fn[T], @type[T]). Call sites infer the type arguments; no
explicit [Int] needed.
\\ id[T] (T) -> T
@fn id x := x;
id(99); # T inferred as Int
id('hi'); # T inferred as String
Parametric sum types power Option and Result:
@type Option[T] := $Some value T | $None;
No exceptions. Absence is Option[T]; fallible results are Result[T, E].
@use 'option';
@use 'result';
picked := option_unwrap_or(Some(42), 0); # 42
fallen := option_unwrap_or(None(), 7); # 7
r := Ok(42);
v := result_unwrap_or(r, 0); # 42
Helpers: option_is_some, option_is_none, result_is_ok, result_is_err.
The default allocator is a bump allocator — fast, never frees. Fine for
run-and-exit programs. For long-running loops (servers, REPLs), wrap
per-iteration work in @with_arena({ … }) so each iteration's allocations are
reclaimed, and use @move_to_parent_arena(value) to keep a value the parent
scope needs:
response := @with_arena({
body := build_response(req);
@move_to_parent_arena(body)
});
Rc[T] (reference counting) is available via @use 'rc'. See
memory.md for the full model.
The stdlib wraps low-level WASI and intrinsics behind ergonomic snake_case
functions, so basic programs read like a high-level language. Full reference:
stdlib.md.
@use 'io'; # print, println, print_int/float/bool, eprint, read_line, exit
@use 'num'; # int_to_str, str_to_int, int_abs/min/max/clamp/pow, float_*
@use 'str'; # str_eq, str_contains, str_slice, str_repeat, str_index_of, …
@use 'mem'; # align_up, mem_fill, mem_eq (portable; also wasm-gc)
@use 'heap'; # heap_align (bump-pointer alignment; wasm-mvp only)
A taste:
@use 'io';
@use 'str';
@fn main := {
name := read_line(); # input
print('Hello, ' ++ name ++ '!');
print_int(str_byte_len(name));
@if(str_contains(name, 'a'), { print('has an a') }, {});
0
};
main();
Data structures: vec (Vec[Int]), hashmap, slice, plus option /
result.
A platform is the host a program runs on — orthogonal to the wasm
memory-model --target (wasm-mvp vs wasm-gc). Choose it with --platform
or sgl.toml [build] platform.
| Platform | How | Output | Strings |
|---|---|---|---|
| native / WASI (default) | sgl run (wasmtime) / sgl build --native (QBE) |
@use 'io' → print, read_line |
linear-memory String |
| bun | sgl run --platform=bun |
console::log, web::console_log_f |
JSString + String bridge |
| web | sgl run --platform=web (browser) |
web::canvas_*, web::set_html |
JSString + String |
The pure stdlib modules (mem, num, str) compile on all platforms;
only I/O differs. On the JS host, JavaScript strings are the JSString type
(WASM JS String Builtins) with a String ↔ JSString bridge — see
js-string-builtins.md. The bun/web platforms also
reach the host's object, async, and event APIs — see §16, Calling host
APIs.
# bun platform — the portable stdlib + the JS console
@use 'num';
web::console_log_str(int_to_str(int_pow(2, 16))); # 65536
Silicon's grammar is intentionally tiny and stable. Almost every "keyword" and
"operator" — @if, @loop, @mut, +, ==, ++ — is not baked into the
grammar. They are defined as strata: data-driven Silicon declarations,
loaded into the compiler, that say how a construct lowers.
# The '++' string-concat operator is a stratum:
@stratum Concat := {
Compiler::register::operator('++');
Compiler::on::lower('++', Concat_lower);
};
This means you can add your own keywords and operators without touching the
grammar or the compiler's TypeScript — you write a stratum that calls
Compiler::*(…) to register and lower the new construct. The built-in language is itself a library of
strata under src/strata/. See the
Strata authoring guide and
strata.md.
Why this matters: it keeps the core language small and bootstrappable while letting the surface syntax grow as a library — the same philosophy as the standard library wrapping WASI.
On the bun/web platforms a Silicon program can reach the host's modern API
surface. @extern imports a host function; the import lives in module env by
default, or in a named host module with mod::field:
\\ @extern dom::get_element_by_id (JSString) -> JSValue; # imports "dom"."get_element_by_id"
Object handles. Two opaque externref handle types let host objects cross
the boundary without copying:
JSString— a JavaScript string (distinct from Silicon's linear-memory UTF-8String), backed by the WASM JS String Builtins.JSValue— any host object (aResponse, aUint8Array, a DOM node, a parsed-JSON value). It is opaque to the guest and engine-garbage-collected.
Handles thread between functions but are never introspected by the guest — they
go back to the host. (externref needs a JS host, so these are --platform=web|bun
only; a --native build rejects them at compile time.)
Generated built-in modules. A growing set of host APIs is generated from
their real specs (Web IDL, @types/node, bun-types) and ships as built-in
modules, callable as module::fn:
os::platform(); # "linux" (Node node:os, Tier-0)
u := url::create('https://a.b/p?x=1'); # construct a URL object (handle)
q := url_search_params::get(url::search_params(u), 'x'); # "1" — handles thread between modules
obj := json::parse(text); # JSValue handle; round-trips back out
json::stringify(obj);
Tiers: Tier-0 marshals linear String and runs on any host — the os/path
string/scalar functions (platform/basename/…) are Tier-0 portable; Tier-1
(bun strings) and Tier-2 (json, the url/headers/text_encoder/
text_decoder constructed interfaces, and the os/path object readers such as
path::parse / os::cpus) cross as zero-copy JSString/JSValue handles (web/bun
only). os/path are thus mixed tier — a program calling only their string
functions stays portable; calling an object reader needs a JS host (gated by E0010).
Async (@async / @await / @suspending). A Promise-returning host import
is marked @suspending; a function that awaits one is @async; @await is the
suspension point. The code reads straight-line:
\\ @suspending @extern bun::resolve (JSString, JSString) -> JSString;
\\ @async resolve_mod (JSString) -> JSString
@fn resolve_mod id := { @await(bun::resolve(id, id)) };
@await is only legal inside an @async body (diagnostic E0016 otherwise).
At run time sgl run drives the suspension through a reactor — using the
engine's JSPI where available (Bun 1.3+, V8) or a portable Asyncify fallback —
so the same source runs on every engine.
Callbacks/events use closures: pass @export_callback(@closure(handler, …))
to a callback-taking host API (§7); the host calls it back on the event.
- Getting Started — install + first project.
- Standard Library — every shipped module.
- Memory model — arenas, escape,
Rc[T]. - JS String Builtins — the JS-host string story.
- Strata authoring — add your own syntax.
- Grammar — the authoritative EBNF.
- Examples —
examples/: fizzbuzz, calculator, strings, floats, web letters, and the bun/web demos.