This page is for readers who already program in another language (Python, JavaScript, TypeScript, Go, Java, C#, Kotlin or Swift) and want to see what Nikaia looks like before they read the specification. It covers the parts you will write every day, one short example each, and says where Nikaia behaves differently from what you probably expect.
Every snippet here compiles and runs with the current compiler, except a line marked
does not compile, which shows what the compiler refuses. To try one, put it in
src/main.nika of a project (see Getting started) and run
nikaia run.
The full rules are in the specification, Part I. Each section below ends with a Specification link to the chapter it comes from.
let x = 10 // immutable: x cannot be assigned again
let mut count = 0 // `mut` says it may change
count += x
Values are immutable unless declared mut. Types are inferred. You write one where you
want to, let big: int = 3000000000.
There are no semicolons at the end of lines. A comment is // or /* … */.
Specification: Part I, §2.1 Variables and assignment
| Type | What it is |
|---|---|
int |
the integer for most numbers. In your code it works exactly like an i64; in memory the compiler may hold it in fewer bits where it knows the values fit |
i64, i32, u8, u64, u32 |
integers of exactly that width, for a hash, a byte, a wire format or C. Every length and index is an i64, and an int goes where an i64 is expected, and back, without as |
f64 |
a floating-point number |
bool |
true or false |
scalar |
one Unicode scalar value, 'a' |
String |
text |
An integer that overflows stops the program instead of wrapping silently. Where you mean
to wrap, you say so: h.wrapping_mul(31).
Conversions are written with as: (total as f64) / (count as f64).
Specification: Part I, §2.2 Primitive data types
let name = "Nika"
println(f"hello, {name}, your name has {name.len()} letters")
println("braces in a plain string are just braces: {name}")
f"…" interpolates. A plain "…" never does, so JSON and CSS can be written as they
are.
You never choose between a string type and a string-slice type. There is one String,
and whether a value is text of its own or a view into text that already exists is the compiler’s
decision.
Specification: Part I, §2.5 Strings, plain and interpolated
if, match and blocks are expressionslet count = 7
let status = if count > 5 { "big" } else { "small" }
if, match and a { … } block each give the value of their last line. You can assign it
to a variable or use it in a calculation, which is why there is no ternary operator.
Specification: Part I, §3.1 Expressions and blocks · §3.2 if / else
for i in 0..<3 { // 0, 1, 2: `..<` stops before the end
println(f"step {i}")
}
let mut n = 0
while n < 5 {
n += 1
}
a..b includes its end and a..<b excludes it. There is for, while, break and
continue. An endless loop is while true { … }.
A for reads the elements of a list and leaves the list as it was. for mut changes each
element in the list itself:
let mut scores: Vec[int] = [70, 85, 92]
for mut s in scores {
s += 5
}
println(scores.iter().join(", ")) // 75, 90, 97
A for loop lends its list to the loop body, so the body cannot add elements to that list or
remove them. Such a change could move the elements the loop is still reading, which in many
languages is a crash or a silent bug at run time. Nikaia refuses it when it compiles:
fn main() {
let mut users: Vec[String] = ["ada", "bob", "ada"]
for u in users {
if u == "bob" {
users.push("x") // does not compile: NK2305
}
}
}
error[NK2305]: `users` is changed here, inside the loop that lends it.
--> main.nika:5:13
|
3 | for u in users {
| ----- `users` is lent to this loop
...
5 | users.push("x") // does not compile: NK2305
| ^^^^^ `users` changes here
|
= help: Collect what the loop wants to add in a list of its own and add it after the loop with `users.extend(more)`, or walk `0..<n` with `let n = users.len()` before the loop.
1 change to data a view still to be used may point into
What the loop wants to add goes into a list of its own, and that list is added once the loop has finished:
fn main() {
let mut users: Vec[String] = ["ada", "bob", "ada"]
let mut more: Vec[String] = []
for u in users {
if u == "bob" {
more.push("x")
}
}
users.extend(more)
println(users.iter().join(", "))
}
ada, bob, ada, x
For removing elements the standard library has retain, which keeps those the function
accepts. The message names such a method (retain, drain, entry, swap) where one fits:
fn main() {
let mut users: Vec[String] = ["ada", "bob", "ada"]
users.retain fn(u) { u != "bob" }
println(users.iter().join(", "))
}
ada, ada
for x in xs.drain() takes the elements out of the list instead of lending them.
Specification: Part I, §3.3 Loops · §6.8 When the compiler says no
fn greet(name: String; greeting: String = "Hello") -> String {
return f"{greeting}, {name}!"
}
println(greet("Nika")) // Hello, Nika!
println(greet("Nika"; greeting: "Hallo")) // Hallo, Nika!
The semicolon in the parameter list is the unusual part. Parameters before it are the
data the function works on and are passed by position. Parameters after it are options:
they always have a default, and they are always passed by name, like keyword arguments
in Python. On main the same split is the command line: positional words and --name
options (The command line).
A String parameter needs nothing more: whether the function gets a view of the caller’s
text or text of its own is the compiler’s decision. ref String is written only in an
extern "C" declaration; elsewhere it is a warning.
Specification: Part I, §5.1 The subject ; config protocol
fn pair_of[T](a: T, b: T) -> Vec[T] {
return [a, b]
}
fn empty[T]() -> Vec[T] {
return []
}
println(pair_of(1, 2).len()) // 2: `T` is `int` here
let names = empty[String]() // nothing says what `T` is: name it at the call
println(names.len()) // 0
A type parameter is written in square brackets, [T], where Java and TypeScript write
<T>. The compiler fills it from the arguments; where no argument says what it is, the call
names it, empty[String]().
Specification: Part I, §4.6 Generics
struct Point {
x: int,
y: int,
}
impl Point {
fn length_squared(ref self) -> int {
return self.x * self.x + self.y * self.y
}
}
let p = Point { x: 3, y: 4 }
println(f"{p.length_squared()}") // 25
let moved = p with { x: 5 } // a copy with one field changed
There are no classes and no inheritance. A struct holds the data, and an impl
block adds behaviour to it, as in Go or Rust. Shared behaviour across types is a
trait (an interface).
Everything is private unless it says pub.
Specification: Part I, §4.1 Structs · §4.2 Constructors · §4.3 Why no classes? · §4.7 Traits
matchenum Shape {
Circle(f64),
Rectangle { width: f64, height: f64 },
}
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rectangle { width, height } => width * height,
}
}
An enum is a value that is one of a fixed set of shapes, and each shape may carry
data, like TypeScript’s discriminated unions or Kotlin’s sealed classes.
match must cover every case. Leave out Shape::Rectangle and the program does not
compile. When a match is not over an enum, else => catches the rest.
Specification: Part I, §3.4 Pattern matching · §4.4 Enums
fn find_nickname(name: String) -> String? {
if name == "Nika" {
return "Nik"
}
return null
}
let nick = find_nickname("Ada") ?? "no nickname"
println(nick) // no nickname
println(find_nickname("Rust")) // does not compile: NK1102, it may be null
A String is always a string. Only a String? may be null, and the compiler makes you
handle it: ?? supplies a fallback, and ?. reaches into a value only where it exists.
Specification: Part I, §2.3 Nullable types · §3.5 Null safety operators
use std::collections
let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map fn(n) { n * 2 }
let mut ages = collections::HashMap()
ages["Nika"] = 7
let age = ages["Nika"] ?? 0 // a key may be missing, so reading one gives a `T?`
A lambda is always written fn(arguments) { body }. When it is the last argument it
stands after the call, as with map above. There is no second short arrow form.
Specification: Part I, §4.5 Collections · §5.2 One lambda form · §5.3 Lambdas
enum AgeError {
Empty,
NotANumber(String),
}
impl Error for AgeError {
fn message(ref self) -> String {
match self {
AgeError::Empty => "the input is empty",
AgeError::NotANumber(text) => f"not a number: {text}",
}
}
}
fn parse_age(text: String) -> int throws {
if text.len() == 0 {
throw AgeError::Empty
}
// …
return 42
}
fn main() {
let age = parse_age("") catch {
println(f"{error}") // inside `catch`, the failure is called `error`
0 // …and the block supplies a replacement value
}
}
A function that can fail says throws. What it can fail with is worked out by the
compiler. If a function calls something that can fail and neither declares throws nor
handles the failure with catch, it does not compile, so no failure is silently dropped.
There is no try, ? or if err != nil: a failure the function does not catch goes up
to its caller.
Specification: Part I, §7.1 Recoverable errors
use std::fs
fn size_of(path: String) -> int throws {
let text = fs::read_to_string(path, fs::Root::Anywhere)
return text.len()
}
fn main() throws {
fs::write("a.txt", fs::Root::Anywhere, "hello") // two files to read
fs::write("b.txt", fs::Root::Anywhere, "world!")
let (a, b) = overlap { // both files are read at the same time
size_of("a.txt")
size_of("b.txt")
}
println(f"{a + b} characters in both files")
let greeting = "hello from a task"
let handle = spawn fn { println(greeting) } // a background task
handle.join()
}
In JavaScript, Python or C#, a function that waits is marked async and its calls say
await. In Nikaia you do not need either, because the compiler finds which functions can
pause. Reading a file pauses the function without blocking the program, you write it as an
ordinary call, and a function that starts to wait leaves its callers as they are.
Lines run in the order they are written. In the program above, overlap { … } runs its lines
at the same time and waits for all of them, and spawn starts a task that runs on its own.
Both compile today. The specification replaces them with one word, async, written before an
expression; this is the design being built
(#983), not yet accepted by the
compiler:
let user = async db::load_user(id)
let rights = async db::load_rights(id)
let prefs = cache::load_prefs(id) // runs while both loads are in flight
render(user, rights, prefs) // waits for both first
async e starts e at once, and the program waits for its value where the value is first
needed, so f(async g(), async h()) runs g and h beside each other and then calls f. A
catch written after an async expression is its fallback. async(handle) gives a handle
with join and cancel, async(detached) starts a task that may outlive its function and
takes the place of spawn, and work over every element of a list is xs.par_iter(), whose
lambdas may pause. The option names handle, deferred and detached are placeholders.
Two tasks that would change the same value at once, or both write to standard output, are
refused when the program is built, and the message names what they share. Where the compiler
cannot tell what a call touches, it does not refuse; today it also accepts two tasks that write
the same file. A task’s error is thrown where the program waits for it, at handle.join() or
at the end of overlap, and the compiler asks for it to be handled there as for any call that
can fail: main above says throws. A task handle that is never joined, cancelled or detached
is refused. Each task is to get a memory budget of its own, and a task that uses it up ends
with task::OverBudget while the rest of the program runs on; this is specified and not built
yet, and memory a Rust crate or a C library allocates for itself is not counted.
Specification: Part I, Chapter 8 Concurrency
fn main() throws { // `join` fails if the task crashed
let counter = SharedMut(0)
let first = spawn fn { counter.update fn(mut v) { v += 1 } }
let second = spawn fn { counter.update fn(mut v) { v += 2 } }
first.join()
second.join()
println(f"counter is {counter.get()}") // counter is 3
}
A value that several tasks change is put into a SharedMut, and it is changed only
through its methods (get, set, update, access). The lock is part of the value, so
you cannot forget to take it. The compiler also refuses code that would wait for I/O while
holding it.
Whether this runs on one core or on all of them is not written here at all. It is one line
in nikaia.toml (Getting started), and
the source stays the same.
Specification: Part I, §6.2 Unified types · §6.3 The four doors · Part II, Chapter 12 Thread safety
Nikaia has no garbage collector, and you do not free memory by hand. A value is cleaned up at the end of the block that owns it, and a file or a socket is closed there too. What makes this work is that a value has one owner unless you write otherwise, and the owner can lend the value or hand it over. The compiler checks how each value is used and tells you, before the program runs, where a use does not fit. The rest of this section shows how that looks in a program, and then where the model ends.
struct Person { name: String, age: int }
fn greet(p: Person) { // only reads `p`: it is lent
println(f"Hello, {p.name}")
}
fn main() {
let ada = Person { name: "Ada", age: 36 }
greet(ada) // Hello, Ada
greet(ada) // Hello, Ada: still ours
let mut team: Vec[Person] = []
team.push(ada.clone()) // the list keeps what it gets: give it a copy
println(ada.name) // Ada
let age = ada.age // a number is copied
println(age + ada.age) // 72
team.push(ada) // the last use: no copy needed
println(team.len()) // 2
println(ada.name) // does not compile: NK2105, `ada` is in the list now
for p in team { // a `for` lends the list
println(p.age) // 36, twice
}
println(team.len()) // 2
}
A function that only reads a parameter is lent the value, and the caller still has it after
the call: greet(ada) can be written twice. A function that changes a parameter says mut
before it, and the caller’s value is changed in place. A function that keeps a parameter
(stores it, returns it, gives it to a task) takes it over, and so does a list you push into;
after that the old name is gone, and .clone() is how both places get a value. Numbers and
other small values are copied wherever they are used. You do not write which of these a
parameter is: the compiler reads it off the body and records it in nikaia.contracts, a file
you commit like a lock file.
A loop that changes the list it walks, and a change to text while a view of it is still used,
are refused before the program runs, and the message names a way that fits. Data that several
owners need at once is written Shared[T] or SharedMut[T]
(Data that more than one task changes).
Ownership teaches these rules from small programs up: views and the data they point into, the ledger, the common errors with their fixes, and where the model ends.
Specification: Part I, Chapter 6 Memory and ownership · §6.5 References and borrowing · §6.7 The borrow contract ledger
// file: src/stock.nika
pub struct Item { pub name: String, pub count: int }
// file: src/main.nika
use std::fs // a standard-library module: every name from it keeps its prefix
fn main() {
let item = Item { name: "apples", count: 3 } // same package: no `use` at all
println(f"{item.count} {item.name}")
}
A package is a directory, and the .nika files in it see each other. Another
package is reached by its name, and what comes from it is always written with its prefix,
fs::read_to_string(…). Nothing is imported unprefixed, so you always see where a name
comes from. pub makes something visible outside its package.
Specification: Part I, Chapter 9 Project organization
A program can describe a text format as a grammar, and the compiler builds a parser for it that hands back values of the program’s own types:
grammar Pairs {
entry rule pair -> (String, i64) = key:NAME "=" value:NUMBER -> { (key, value) }
rule NAME -> String = s:alpha1 -> { s }
rule NUMBER -> i64 = n:dec[i64](digit+) -> { n }
}
fn main() throws {
let (key, value) = Pairs::pair("width = 80")
println(f"{key} is {value}")
}
Input the grammar does not accept makes the call fail, and the program handles that
with catch like any other failure. Grammars and parsers teaches
grammars in full, from this example up to a complete calculator.
Specification: Part II, Chapter 10 Metaprogramming
assert.examples/: complete programs that compile and run.