A Tour of the Syntax

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.


Variables

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

Basic types

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

Text and interpolation

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 expressions

let 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

Loops

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

Changing a list while a loop reads it

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

Functions

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

Generic functions

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

Structs and methods

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

Enums and match

enum 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

Null safety

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

Lists, maps and lambdas

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

Errors

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

Waiting and doing things at the same time

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.

What the compiler checks about tasks

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

Data that more than one task changes

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

Memory: lending and keeping

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

Modules

// 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

Grammars

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


Where next

This site is open source. Improve this page.