Getting started with Silk
This tutorial takes you from an empty directory to a Silk program that runs. It assumes no knowledge of the language. Every program on this page compiles with the current compiler.
Silk is a small, explicitly typed language. Two things make it unusual, and both appear before the end of this page:
- Ownership is affine and explicit. A value of a move-only type — a struct, an owned
collection, an exclusive borrow — is used at most once, and handing it away is written with the
movekeyword. Scalars,bool, strings, and shared borrows are copyable and are reused freely. - Effects are values. A computation that can fail, or that needs a capability from its caller,
has that fact in its type. Running it is written with the
runkeyword.
Create a project
silk init writes a manifest, a source directory, and an entry point:
$ silk init hello
$ cd helloYou get this layout:
hello/
silk.toml # [package] name, version, root
.gitignore # ignores the build/ directory
src/main.silk # the entry point named by rootThe generated silk.toml names the entry file:
[package]
name = "hello"
version = "0.1.0"
root = "src/main.silk"And the generated src/main.silk is the smallest program that does nothing:
pub effect fn main() -> () {
return ()
}Run it:
$ silk runYour first program
Replace src/main.silk with a program that computes a value. An entry point declared
pub fn main() -> i32 returns its result as the process exit status:
pub fn main() -> i32 {
let total = 2 + 3
return total
}Three rules are already visible:
pubmakes a declaration visible outside its module. Without it a declaration is private to the file.letbinds a name. The type is inferred; an unconstrained integer literal isi32.- A non-unit path must return. Silk has no trailing-expression return. Every reachable path of
a function returning a value must end in
returnor another terminal operation; fallthrough is aSEM0130error.
Check the program without running it:
$ silk checkFunctions, mutation, and loops
Bindings are immutable unless you write let mut. The only loop is while:
fn double(value: i32) -> i32 {
return value * 2
}
pub fn main() -> i32 {
let mut total = 0
let mut index = 0
while index < 5 {
total = total + double(index)
index = index + 1
}
return total
}Notes on what is not here:
- There is no
forloop, no iterator, and no range.whileis the whole vocabulary, plusbreakandcontinue. ifis a statement, not an expression. There is nolet x = if .... Declare the bindingmutand assign it in each branch, orreturnfrom the branches. (matchis an expression, but its scrutinee must be a struct or union — it cannot branch on a scalar comparison.)- Conditions take no parentheses and the braces are mandatory.
Arithmetic is homogeneous: both operands must already be the same integer type. Silk performs no
implicit widening at all, so i32 + i64 is an error rather than a conversion. Note also that
there is no negative integer literal — a leading - is a separate prefix operator, and subtracting
from 0 is the common way to write a negative constant.
Structs and ownership
A struct groups named fields. Fields are listed one per line with no commas, and each field
carries its own visibility:
pub struct Point {
pub x: i32
pub y: i32
}
fn manhattan(point: Point) -> i32 {
let mut total = 0
if point.x < 0 { total = total - point.x } else { total = total + point.x }
if point.y < 0 { total = total - point.y } else { total = total + point.y }
return total
}
pub fn main() -> i32 {
let origin = Point { x: 3, y: 0 - 4 }
return manhattan(move origin)
}The move in manhattan(move origin) is the ownership rule in action. Point is a struct, and
structs are move-only: passing one by value hands the whole value to manhattan, and Silk
requires you to say so. Dropping the move is an OWN0003 error, and using origin again after
the move is an OWN0001 error.
This is why the loop counters in the previous section needed no move. i32, bool, strings,
shared borrows, and fixed arrays of copyable elements are copyable: passing one to a function
copies it, the binding stays usable, and OWN0003 never fires. Only move-only types require the
keyword.
One rule spans both categories: writing move explicitly always consumes the binding, even for a
copyable type. let x = 1 followed by f(move x) makes any later use of x an OWN0001 error,
so reach for move when you mean it rather than as decoration.
The distinction is the single most common surprise for a new Silk programmer, and it is deliberate: the point at which a value stops being yours is always written down.
Borrowing instead of giving away
When a function only needs to read or update a value in place, borrow it rather than move it.
Borrows are written & (shared) and &mut (exclusive), and they exist only as call arguments:
import silk.usize as usize
fn total(values: &[i32], length: usize) -> i32 {
let mut sum = 0
let mut index = usize.add(0, 0)
while index < length {
sum = sum + values[index]
index = index + 1
}
return sum
}
fn scale(values: &mut [i32], length: usize, factor: i32) {
let mut index = usize.add(0, 0)
while index < length {
values[index] = values[index] * factor
index = index + 1
}
return
}
pub fn main() -> i32 {
let mut values = [1, 2, 3, 4]
let scaled = scale(&mut values, usize.add(4, 0), 2)
return total(&values, usize.add(4, 0))
}[1, 2, 3, 4] is a fixed array whose length is part of its type ([i32; 4]). &values forms a
slice over it. There is no implicit array-to-slice conversion: the & is required. An out-of-range
index traps rather than reading past the end.
Slice lengths and indices are usize, and this is where the no-implicit-conversion rule shows its
teeth. A bare 0 in an unconstrained position infers i32, which is not a usize and will not be
converted for you, so let mut index = 0 used as an index is a SEM0033 error. Writing
usize.add(0, 0) seeds the binding at the type you want; it is the idiom used throughout
examples/algorithms/.
A borrow cannot be stored in a local of its own — let view = &values is rejected. Borrows live
for the duration of a call and no longer, which is how Silk stays memory-safe without lifetime
annotations.
Unions and match
Silk has no enum. Instead, a value can have a union type written A | B, and match
takes it apart. match is an expression, so it can produce a value:
pub struct Empty {}
pub struct Full {
pub amount: i32
}
fn describe(state: Full | Empty) -> i32 {
return match move state {
Full { amount } => amount
Empty nothing => 0
}
}
pub fn main() -> i32 {
let full = Full { amount: 7 }
return describe(move full)
}Points worth noting:
- Arms are separated by line breaks. No commas.
Full { amount }destructures the field into a binding of the same name. A pattern must account for every field; write..to acknowledge the ones you skip.Empty nothingbinds the whole member rather than its fields.match move statestates the access mode. The alternatives arematch value(only for copyable values),match &value, andmatch &mut value.- Matches are checked for exhaustiveness. Leaving out
Emptyis a compile error naming the member you missed, so adding a member to a union tells you every place that must change.
Effects: failure in the type
An effect fn returns a computation rather than a result. Its type has three channels:
the success type, a failure row after !, and a requirement row after ?. Here is a function
that can fail:
pub struct Overflowed {}
effect fn checked(value: i32) -> i32 ! Overflowed {
if value > 100 {
fail Overflowed {}
}
return value * 2
}
pub effect fn main() -> () ! Overflowed {
let doubled = run checked(21)
return ()
}What each piece does:
-> i32 ! Overflowedsays: succeeds withi32, or fails withOverflowed. The failure is part of the signature, so a caller cannot forget it.fail Overflowed {}stops the computation with that value. It is not an exception — nothing unwinds invisibly, and the type system already knew it could happen.- Calling
checked(21)only builds the computation.runexecutes it. Forgettingrunis a type error, not a silently ignored value. runpropagates the failure into the enclosing effect's row, which is whymainalso declares! Overflowed.- Any concrete owned failure value may reach an effectful entry point without marker conformance. An unhandled failure terminates with status 1 and retains its canonical type identity.
An effectful entry point returns () and reports success as exit status 0. An unhandled failure
becomes a non-zero status.
Failures are handled with ordinary library functions rather than special syntax — Effect.catch,
Effect.catchAll, and Effect.retry are written in Silk in effect.silk, not built into the
compiler. The requirement row (?) works the same way for capabilities such as an allocator or a
logger: a caller supplies them with provide, and the row shrinks as they are supplied.
Where to go next
- The language reference states the rules in full: lexical form, types, memory and ownership, and the effect system.
- The standard library reference lists every module and public declaration.
- The diagnostic index explains every error code, including the
OWN0001andSEM0130mentioned above. examples/algorithms/in the repository holds larger programs — quicksort, FFT, CRC-32, game of life — that compile and run today.