Tutorial · eleven chapters, read in order

Learn Science by running it

This page assumes you have never seen Science and teaches it from the first command. Each chapter gives you a reason before it gives you a construct, and every construct arrives in a program you can type, compile and run. Read it top to bottom. Chapter 7 depends on chapter 5, chapter 5 depends on chapter 2, and skipping is how the ownership chapter stops making sense.

The other three pages are not a tutorial and do not try to be. The reference explains what a construct means. The syntax page is the inventory of everything that can be written. The rationale argues for the spellings against the ones they beat. Where to go next says which one to open, and when.

Chapters 11 Science listings 29 — 21 clean, 8 deliberate errors Of the 21, built and run 15, output pasted Check clean, refuse to build 6, each said so in its chapter Compiler sciencec 0.0.1

What was run to write this page, and what the numbers mean

Every Science listing below was extracted from this file's own source, written to a scratch file, and put through sciencec check with its exit code read — not its output searched for the word "error", which reports a clean file for a compiler that never started. Twenty-one exit 0. The other eight are on this page in order to fail, each beside the diagnostic it produces.

Fifteen of the twenty-one were then compiled with sciencec build, linked, executed, and their real stdout pasted underneath as a trailing comment. Nothing in an output comment was typed by hand.

Six check clean and the backend refuses them, and each is marked in the chapter that teaches it rather than left for you to discover: the declared main in chapter 1, the counting loop in chapter 3, the method block in chapter 5, the error pair in chapter 8, and both the interface and the generic in chapter 9. This is the most important thing to understand about the compiler's current state, and chapter 1 states it before you write a line: check runs the whole front end and accepts a great deal more of this language than build can emit.

Every error message reproduced here is the compiler's own stderr, produced by writing that exact program to disk and running it, down to the span underlines and the note wording. Teaching a language through its diagnostics is this project's stated design principle; an invented error message would contradict the thing being taught.

The compiler was built from a clean checkout, in an empty target/, and every number above comes from that binary. The boundary between check and build moves, and it moves in one direction. If a listing here is marked as refused and your compiler builds it, your compiler is right and this page is behind.

1 · Install and run#

There is no installer and no released binary. You build the compiler from source, once, and then you have a sciencec that turns a file into a native executable. This chapter ends with you running a program you wrote.

What you need first

A Rust toolchain, because the compiler is written in Rust. And LLVM 18.1, because the backend calls LLVM-C directly and links against it. The version matters: the backend is written against 18.1's C API, and a different major version is a link error rather than a warning.

$ winget install LLVM.LLVM --version 18.1.8

That is the Windows command. On macOS it is brew install llvm@18, and on Debian the LLVM apt repository's llvm-18-dev. Whichever you use, note where it put its bin directory — you need it on PATH in a moment, and the failure when it is missing is silent.

Build the compiler

Two cargo build commands. The order does not matter and both are needed. The first produces the runtime archive that every compiled Science program links against. The second produces the compiler, with its LLVM backend switched on.

$ git clone https://github.com/Angel-Zeledon/sciencec.git
$ cd sciencec
$ cargo build -p science-rt
   Compiling science-rt v0.0.1 (/tmp/sciencec/crates/science-rt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.60s
$ cargo build -p sciencec --features llvm
   Compiling science-mir v0.0.1 (/tmp/sciencec/crates/science-mir)
   Compiling science-codegen v0.0.1 (/tmp/sciencec/crates/science-codegen)
   Compiling salsa-macros v0.28.2
   Compiling science-regions v0.0.1 (/tmp/sciencec/crates/science-regions)
   Compiling salsa v0.28.2
   Compiling science-db v0.0.1 (/tmp/sciencec/crates/science-db)
   Compiling sciencec v0.0.1 (/tmp/sciencec/crates/sciencec)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 58s

That is the tail of a build from an empty target/ on one laptop: two minutes, with the rest of the workspace scrolling past above the seven lines shown. Both artefacts land in target/debug/ — sciencec, and beside it science_rt.lib, which is where build looks for the runtime.

The two failures that cost an afternoon, and how each one looks

--features llvm is not the default. Without it the backend compiles to a stub, and build stops at SC0400 saying no backend is compiled in. That is deliberate: the workspace has to build for a contributor who has no LLVM installed.

cargo build -p science-rt is a separate command because Cargo builds a dependency's rlib and not its staticlib. Building sciencec does not produce science_rt.lib. Skip it and build stops at SC0402 — the message lists the two directories it searched and names the command that fixes it.

Put LLVM on PATH, before anything else

A sciencec built with --features llvm loads DLLs out of LLVM's own bin directory. If that directory is not on PATH the process exits 127 and prints nothing at all — no diagnostic, no usage message, no clue. A script that tests for the absence of an error will call that a pass.

$ export PATH="/c/Program Files/LLVM/bin:$PATH"
$ sciencec --version
sciencec 0.0.1

Check the exit code, never the absence of output. sciencec check on a clean file prints nothing and exits 0, so "no output" is the success case and the silent-failure case at once. Everything on this page was verified by reading $?, which is also how you should drive it from a script or a CI job.

Your first program

A file is a module, and its top-level statements are its program. There is no main to declare and no ceremony to write. Put this in hello.science:

print("hello, world")
# hello, world
$ sciencec check hello.science
$ echo $?
0
$ sciencec build hello.science
$ ./hello
hello, world
$ echo $?
0

The trailing # hello, world is this page's convention: a comment at the end of a listing holds that program's real stdout. Comments run from # to the end of the line. Where a listing has no such comment it was checked and not run, and the chapter says why.

Write the script form, not main

A file may have top-level statements or a main, never both — two answers to "what runs first" is an error. Both are legitimate Science, and this tutorial writes every program in the script form, for one blunt reason:

def main():
    print("hello, world")
error[SC0400]: this `sciencec` cannot build an entry point whose `Error?` does not come back through `sret`; stage 1 only knows the two-word interface object
  = note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error

That program checks clean and exits 0. It does not build, today, because the backend's entry-point lowering has not caught up with the declared main form. The script form has no such gap, so it is the one every listing here uses — and it is also the shorter, better thing to write for a program of this size.

Read this before chapter 2: check and build are far apart

sciencec check runs the entire front end — lexing, parsing, name resolution, type checking, and the borrow checker. It is complete enough that every listing on this page passes it. sciencec build emits a real but bounded subset: the backend admits a construct only once a program using it has been built, linked, run and checked against its expected output, and refuses everything else rather than emitting something nobody has executed.

That rule was not free advice. Five silent miscompiles have been caught by running the output rather than reading it. So a program can exit 0 from check and be refused by build with an SC0400 that names exactly what it could not lower. Six listings on this page are in that state, each flagged where it appears. When you hit one of your own, the compiler's message is more current than anything written here.

2 · Values and bindings#

You cannot learn a language whose output you cannot see, so this chapter gets you printing first. It introduces one word, be, that does two jobs, and the string form that lets every later chapter show its work.

One word binds, and the same word assigns

let name be value introduces a name. name be value assigns to one that already exists. = is not an assignment operator in Science at all, and writing one is a parse error that names the word you wanted.

A binding is immutable unless it says mutable. The type annotation after the name is optional and inferred when you leave it off.

let celsius be 21.5
let kelvin be celsius + 273.15
print(f"{celsius} C is {kelvin} K")

let mutable samples: Int be 0
samples be samples + 1
samples be samples + 1
print(f"{samples} samples")
# 21.5 C is 294.65 K
# 2 samples

f"…" is an interpolating string: anything inside braces is an expression, evaluated and rendered into the string. This tutorial leans on it from here on, because it turns any expression into something you can see.

Three things this chapter has to tell you, because you meet all three today

Print through f"…", always. print(x) where x is not already a String checks clean and does not build: print takes borrowed any Display, and the backend emits no vtables yet. print(f"{x}") goes through the interpolation builder instead and works for every type on this page. It is why you will not see a bare print(n) in any listing here.

mutable is not enforced yet. let n be 0 followed by n be 3 checks, builds, and prints 3. The keyword records your intent and a reader can rely on it; the compiler does not yet. Write it correctly anyway — the check is coming, and code written without it will not survive it.

Annotate a mutable number you intend to do arithmetic on. let mutable n be 3 then n be n - 1 checks clean and then fails to build: the literal's type is left unresolved and the backend refuses a value whose type it does not have. let mutable n: Int be 3 builds. That is why every counter on this page carries its type.

The primitive types, and the literals that reach them

Science has twelve numeric types and no implicit conversion between any two of them. A literal takes its type from a suffix, from an annotation, or from what it is used with. Converting is always written out, with as, including when it loses precision.

let counted be 1_000_000
let mask be 0xFF
let flags be 0b1010
let small be 7u8
let precise be 2.5f32
let avogadro be 6.02e23

let widened be small as I64
let truncated be 2.9 as I64

print(f"{counted} {mask} {flags} {small} {precise} {avogadro}")
print(f"{widened} {truncated}")
# 1000000 255 10 7 2.5 6.02e23
# 7 2
let label be "station-7"
let ready be true
let initial be 's'
print(f"{label} {ready} {initial}")
# station-7 true s
TypeSuffixWhat it is
I8 I16 I32 I64i8 i16 i32 i64Signed integers. Int is another name for I64.
U8 U16 U32 U64u8 u16 u32 u64Unsigned integers.
F32 F64f32 f64Floats. Float is another name for F64.
F16 BF16—Half precision. No suffix; reached through an annotation.
Bool—true and false.
Char—One Unicode scalar, in single quotes: 'x'.
String—An owned, growable string. Literals are "…".
()—Unit. One value, written (). What a function with no result returns.

Two wrinkles worth knowing before they cost you ten minutes. Int and I64 are the same type but are not yet interchangeable everywhere in the checker: a function declared -> I64 whose body returns a String.length() is SC0525, because length is declared Int — match the spelling the other side uses. And in the backend the aliases are not equal either: a value annotated F64 builds where the same value annotated Float does not. The String methods — String.new(), push_str, length, is_empty — all check and none of them build yet, which is why every string on this page is a literal.

Where Python is better at this, and it is not close

Python

counted = 1_000_000
small = 7
huge = 2 ** 200      # exact
share = counted / 7

Science

let counted be 1_000_000
let small be 7u8
# no arbitrary precision
let share be counted / 7

Python has one integer type and one float type. The integer is arbitrary precision, so 2 ** 200 is exact and nothing overflows, and there is no suffix to learn and no as to write. For a script that adds two numbers together, Python is strictly less to know and cannot get the wrong answer by silent truncation. That is a real advantage and not a small one.

What Science buys with the twelve types is that an F32 array is four bytes an element because you said so, and a U8 pixel buffer is one. That trade is only worth taking when the size of the thing in memory is part of the problem you are solving. If it is not, Python is the better tool, and this page would rather say so than pretend otherwise.

Three mistakes you will make in the first hour

Each of these is a real diagnostic, produced by writing the program and running check. Read the help: line — where a mechanical fix exists, the compiler carries the replacement text, so an editor or an agent can apply it without reading the prose.

let n be 3
if n == 3:
    print("three")
error[SC0016]: equality is written `is`
 --> equality.science:2:6
  |
2 | if n == 3:
  |      ^^ `==` is not an operator in Science
  |
  = note: there is one spelling for each comparison: `is` and `is not` for identity, and `>`, `<`, `>=`, `<=` for order
  = help: write the word instead: `is`
1 error

Identity is written with words and order with symbols, and neither has an alternative. a is b, a is not b, a > b, a <= b. Mixing them in one expression is normal and correct: if name is not "" and hits >= 10:.

let label be "station-7"
print(f"{label.length}")
error[SC0528]: `String` has no field `length`
 --> field.science:2:16
  |
2 | print(f"{label.length}")
  |                ^^^^^^ no such field
1 error

Parentheses mean call; their absence means a field. There is no uniform access in Science and no property syntax: label.length() is a method call, label.length is a field access, and they are different things the compiler will not guess between. This rule is absolute, and it is the one Python and JavaScript arrivals trip over most.

let shape be 3
print(f"{shape}")
error[SC0102]: expected an identifier, found `shape`, which is reserved for a later phase
 --> reserved.science:1:5
  |
1 | let shape be 3
  |     ^^^^^ expected an identifier, found `shape`, which is reserved for a later phase

error[SC0105]: expected an expression, found `shape`, which is reserved for a later phase
 --> reserved.science:2:10
  |
2 | print(f"{shape}")
  |          ^^^^^ expected an expression, found `shape`, which is reserved for a later phase
2 errors

Some words are held for phases the language has not reached. The ones you are most likely to reach for in this domain are shape, model, tensor, mod, kernel, yield, union, assert and import. Write dims, weights, values, remainder, window, produced, merged, check. Deliberately not reserved, because they are what this audience actually types: grad, dim, dims, axis, device, dtype, unit, alias. The full tables are on the syntax page.

3 · Control flow#

Blocks are indentation, opened by a :. There is no while. There are two loops and a conditional, and the conditional is an expression — which removes most of the reasons you would otherwise reach for a mutable variable.

if, and the inline form

An if may have an indented body, or a body written inline after the :. The inline body ends where the expression ends, not at the end of the line — which is why if a: b else: c parses at all. else cannot continue an expression, so the then-branch is finished the moment the parser meets it. "Fits on one line" is a consequence of that rule, not the rule itself.

An if has a value, so it can be the whole body of a function.

def band(celsius: F64) -> I64:
    if celsius < 0.0:
        return 0i64
    if celsius < 25.0: 1i64 else: 2i64

print(f"{band(-4.0)} {band(21.5)} {band(40.0)}")
# 0 1 2

An inline body may be an expression, an assignment, or return, break or continue. It may not be a let, which would bind a name nothing could then use. A dangling else binds to the innermost if, and that is a language rule rather than an artefact of how the parser is written.

loop, break, continue

loop: runs its body until something breaks out. It takes no condition. A condition goes in the body, negated, as the first statement.

let mutable reading: Int be 5
loop:
    if reading <= 0:
        break
    if reading is 3:
        reading be reading - 1
        continue
    print(f"reading {reading}")
    reading be reading - 1
print("done")
# reading 5
# reading 4
# reading 2
# reading 1
# done

Where while would have been better, and it would have

Python

while reading > 0:
    print(reading)
    reading -= 1

Science

loop:
    if reading <= 0:
        break
    print(f"{reading}")
    reading be reading - 1

Python's header states the loop's condition in the one place a reader looks for it, in the positive, in four words. Science spends three lines, buries the condition one line inside the body, and makes you negate it — and negating a condition is where off-by-one bugs come from. For this loop Python is plainly better and nothing about the Science version is an improvement.

What loop buys is that there is exactly one unbounded loop form, so a reader never has to work out whether a given loop tests at the top or the bottom, and a loop that is really a count is pushed into the range form below, where it belongs. Whether that is worth three lines is a judgement. The compiler at least tells you which word it wants:

let mutable i: Int be 0
while i < 3:
    i be i + 1
error[SC0142]: the unbounded loop is written `loop`
 --> loopword.science:2:1
  |
2 | while i < 3:
  | ^^^^^^^^^^^ `while` is not a keyword in Science, and `loop` takes no condition
  |
  = note: end it with the condition negated: make `if not …: break` the first statement of the body, or write a count as a range, `for i in 0..n:`
  = help: open the loop with: `loop`
1 error

for … in, over a range or a collection

A count is a range. 0..n is half-open and stops one short of n; 0..=n includes it. Either end may be any expression. The same for walks a collection.

let mutable total: Int be 0
for i in 0..5:
    total be total + i
print(f"{total}")

That listing checks and does not build — the second of six on this page

sciencec check exits 0 on it. sciencec build refuses it, because the front end leaves the loop's range and iterator temporaries untyped and the backend will not emit a value whose type it does not have. Verbatim:

error[SC0400]: this `sciencec` cannot build a value the front end left untyped: its `Ty` is `TyKind::Error` and no diagnostic was reported for it. The three this compiler has met are a call to `print` or `write`, whose signatures `science-resolve`'s `builtins.rs` withdrew; a `for` loop's range and iterator temporaries; and a discriminant temporary, which is handled rather than refused
  = note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error

That note is worth reading once, slowly. It is the boundary this whole page is written against, and it is the compiler's own list rather than somebody's summary of one. Until the range temporaries are typed, write a counting loop as a loop with an annotated counter — which is what the listing above it does, and that one builds and runs.

break and continue end or skip the innermost loop, and read the same over a range as over a collection. There is no labelled break.

4 · Functions#

def declares a function. A parameter's type is mandatory; the return type follows -> and is omitted when there is nothing to return. The body's last expression is the function's value, and return exists for leaving early.

The four things a signature says

Read def kelvin(celsius: F64) -> F64: as: the name, the parameters with their types, what comes back, and — from chapter 7 onwards — whether each parameter is taken or borrowed. That last piece is what makes a Science signature carry more than most, and it is why borrowed appears below before it has been explained.

def kelvin(celsius: F64) -> F64:
    celsius + 273.15

def announce(label: borrowed String):
    print(f"--- {label} ---")

def safe_band(celsius: F64) -> I64:
    if celsius < -273.15:
        return -1i64
    if celsius < 25.0: 1i64 else: 2i64

announce("readings")
print(f"{kelvin(21.5)}")
print(f"{safe_band(-300.0)} {safe_band(21.5)}")
# --- readings ---
# 294.65
# -1 1

announce has no ->, so it returns unit, (). Writing -> () means the same thing. kelvin has no return: its last expression is its value, which is the ordinary way to write a function that computes one thing.

A function cannot read a script binding. The top-level statements of a file are a body of their own, and the names they bind are local to it; a def sees other items — functions, types, choices, constants — and its own parameters, and nothing else. That is why everything a function needs above is passed in.

The strict () rule

This came up in chapter 2 as a mistake. Here is the rule it comes from. A call always takes parentheses, and their absence always means a field. text.length() calls a method; doc.title reads a field; doc.title() would call a method named title, which is a different thing that may not exist. There is no form in which the parentheses are optional — not for a zero-argument method, not for a method used as a value, not anywhere.

The payoff is that you can tell from the call site alone, with no knowledge of the type, whether a line runs code. That matters for reading code you did not write, which is most code.

Where Ruby is better at this

Ruby

class Doc
  attr_reader :title
  def width = title.length
end

doc.title      # a field today
doc.width      # a method today
               # callers cannot tell,
               # and do not need to

Science

type Doc:
    title: String

Doc has:
    def width(self) -> Int:
        self.title.length()

doc.title        # a field, forever
doc.width()      # a method, forever

Ruby's uniform access principle is a genuinely better property for a library author. A field can become a computed method without touching a single caller, so the public surface of a type is not frozen by the day-one decision about how a value is stored. In Science that change is a breaking change: every doc.title has to become doc.title().

Science takes the other side because the cost lands on a different person. Ruby moves it from the library author to every reader, who now cannot tell whether a line is a memory read or an arbitrary amount of work — which in numerical code, inside a loop, is the difference that matters. Neither answer is free. This one is at least visible from the call site.

5 · Your own types#

Two declarations cover the shapes data takes. type is a record — a value with named fields, all present at once. choice is a sum — a value that is exactly one of several named cases. A third, has, hangs functions off a type.

type: a record

Fields are declared one per line under the name. Construction takes named arguments, all of them mandatory, in any order. There is no positional form and no partial construction, so a record that exists has every field. A field is read with . and no parentheses.

choice: one of several cases

Variants are listed one per line. A variant may carry a positional payload or nothing at all. It may be named bare or qualified by its type — Ground and Station.Ground mean the same thing, and the qualified form is what disambiguates when two choices in scope share a variant name.

type Reading:
    sample: I64
    celsius: F64

choice Station:
    Ground
    Balloon(F64)
    Buoy(F64, F64)

def kelvin(r: borrowed Reading) -> F64:
    r.celsius + 273.15

def altitude(station: Station) -> F64:
    match station:
        Ground: 0.0
        Balloon(metres): metres
        Buoy(latitude, longitude): 0.0

let r be Reading(sample: 1i64, celsius: 21.5)
print(f"sample {r.sample} at {r.celsius} C = {kelvin(r)} K")
print(f"{altitude(Balloon(1200.0))} {altitude(Ground)}")
# sample 1 at 21.5 C = 294.65 K
# 1200.0 0.0

match is the next chapter. It appears here because there is no other way to get at a choice's payload, and a chapter that introduced choice without showing you how to read one would be teaching half a construct.

has: methods and associated functions

A Type has: block holds functions that belong to the type and come from no interface. One whose first parameter is self is a method, called as value.name(). One with no self is an associated function, called through the type as Type.name() — which is how a type gets a constructor.

type Reading:
    sample: I64
    celsius: F64

Reading has:
    def at(sample: I64, celsius: F64) -> Reading:
        Reading(sample: sample, celsius: celsius)

    def kelvin(self) -> F64:
        self.celsius + 273.15

let r be Reading.at(1i64, 21.5)
print(f"{r.kelvin()}")

That listing checks and does not build — the third of six

Methods are the construct being worked on right now, and today the backend cannot resolve the receiver. check exits 0 — the method is found, the receiver is typed, the borrow checker runs — and build stops here:

error[SC0400]: this `sciencec` cannot build a call to the method `kelvin`: its receiver is a `Self` this crate cannot resolve to a concrete type, and Decision 11's method lookup does not put what it found in the tree
  = note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run
1 error

Write methods anyway — they are the right way to organise a type and they type-check today. But if you want a program that runs this week, a free function taking borrowed Reading does the same job and builds, which is why the listing above it is written that way.

A record whose fields are numbers builds and runs; one with a String field does not. Storing a string into a record needs an owned allocation the backend has no slot for yet. It checks clean. Every record on this page is numeric for that reason, not because records are meant to be.

WrittenMeans
type Reading:A record. Fields indented under it, one per line.
type Celsius is F64A type alias. A different declaration, told apart by is.
choice Station:A sum type. Variants indented under it, one per line.
Reading(sample: 1i64, celsius: 21.5)Construction. Named, mandatory, any order.
Balloon(1200.0)A variant with a payload. Positional, because a variant's payload is.
Reading has:Inherent functions. A self first parameter makes it a method.
type Pair of (A, B):Generic parameters. Two or more take parentheses; see chapter 9.

6 · match, patterns, exhaustiveness#

match takes a value and a list of patterns and produces the body of the first pattern that fits. It is an expression, so it has a value. And it is exhaustive: if there is a value no arm covers, that is a compile error rather than a silent fall-through, and the error names the value you forgot.

The pattern forms

A pattern is a variant with its payload bound to names, a literal, a tuple of patterns, a record with its fields named, a bare name that catches everything and binds it, or _ which catches everything and binds nothing. Alternatives are joined with |. An arm's body is inline after the :, or indented under it.

choice Station:
    Ground
    Balloon(F64)
    Buoy(F64, F64)

def altitude(station: Station) -> F64:
    match station:
        Ground: 0.0
        Balloon(metres): metres
        Buoy(latitude, longitude): 0.0

def quadrant(point: (I64, I64)) -> I64:
    match point:
        (0, 0): 0i64
        (x, 0): 1i64
        (0, y): 2i64
        (_, _): 3i64

def band(n: I64) -> I64:
    match n:
        0 | 1 | 2: 0i64
        3 | 4: 1i64
        _: 2i64

print(f"{altitude(Balloon(1200.0))}")
print(f"{quadrant((0i64, 3i64))}")
print(f"{band(4i64)} {band(9i64)}")
# 1200.0
# 2
# 1 2

Both altitude functions on this page take Station by value rather than borrowed Station, and that is the backend's constraint rather than the language's: a match on a borrowed choice checks clean and does not build, because reading the discriminant through a borrow is a lowering the backend has not run. Take the choice by value until it does.

What the compiler says when an arm is missing

Delete the Buoy arm and the program stops compiling. The diagnostic does not say "non-exhaustive"; it names the shape of the value that has no answer, so you can paste it back in as the arm you need.

choice Station:
    Ground
    Balloon(F64)
    Buoy(F64, F64)

def altitude(station: Station) -> F64:
    match station:
        Ground: 0.0
        Balloon(metres): metres

print(f"{altitude(Ground)}")
error[SC0250]: `Station` has a value no arm of this `match` covers: `Buoy(_, _)`
 --> missing_arm.science:7:11
  |
7 |     match station:
  |           ^^^^^^^ this is `Station`
  |
  = note: a `match` answers for every value or the program has no answer for the rest (§4.5); write an arm for `Buoy(_, _)` — or a `_` arm, which covers everything the arms above leave
1 error

This is the property that makes choice worth using. Add a fourth variant to Station six months from now and every match in the program that does not handle it fails to compile, by name, with a file and a line. A _ arm switches that off, which is sometimes right and is always a decision you made on purpose.

Where Rust is better: there are no pattern guards

Rust

match reading {
    n if n < 0   => "below",
    n if n < 25  => "mild",
    _            => "hot",
}

Science

match reading:
    n:
        if n < 0: "below"
        else: if n < 25: "mild"
        else: "hot"

Rust lets a condition live in the pattern column, so a match stays a table you read down: every line is test on the left, result on the right. Science has no guard — n if n > 0: is a parse error — so the condition moves into the arm body and the table collapses into one arm with an if chain inside it. For a match whose cases are numeric ranges rather than variants, Rust reads better, and the Science version is worse than the plain if chain it is pretending not to be.

There is no @ binding either, so you cannot name the whole value and destructure it in the same pattern. Both absences are on the syntax page's list, with the diagnostic each one currently produces.

An arm may also not produce a value at all. panic("…") has type Never, which fits wherever a value is wanted, so one arm can bail out while the others produce an F64.

7 · Ownership#

This is the chapter the language is for. Science has no garbage collector and no reference counting: every value has exactly one owner, and the compiler releases it at a program point it can name. What is unusual is not that rule — Rust has it — but that you never write a region, ever, anywhere. There is no lifetime syntax in the grammar to write, so there is nothing to elide and nothing to get wrong.

The rules, in the order you will meet them

  1. Every value has one owner.
  2. Assigning, passing or returning a value moves it: the old name is empty afterwards.
  3. A type that implements Copy is duplicated instead of moved.
  4. A value may be borrowed shared any number of times, or exclusively once, never both at once.
  5. No borrow outlives what it points at.
  6. Leaving scope destroys the value, running Drop if it has one.

A move, and what it costs you

A String owns a heap allocation, so passing one to a function hands over that allocation. The function now owns it, and the caller's name is empty. Here is the same call written twice:

def announce(label: String):
    print(f"--- {label} ---")

let label be "station-7"
announce(label)
announce(label)
error[SC0301]: use of a moved value
 --> moved.science:6:1
  |
5 | announce(label)
  | --------------- `label` is moved here
6 | announce(label)
  | ^^^^^^^^^^^^^^^ ...and moved again here, after the move
  |
  = note: every value has one owner, and assigning, passing or returning it transfers that owner (§6.1 rules 1 and 2), so the name is empty afterwards (rule 3)
  = note: borrow at the earlier use instead of moving, or give the second use a value of its own; a type that is not `Copy` is never copied implicitly
1 error

Read the three parts. The dashes on line 5 are where ownership left. The carets on line 6 are where it was needed again. The first note states the rule that was broken and cites it. The second offers the two fixes, and the first of them is the one you want here: announce does not need to own the label, it only needs to read it. Say so in the signature.

def announce(label: borrowed String):
    print(f"--- {label} ---")

let label be "station-7"
announce(label)
announce(label)
# --- station-7 ---
# --- station-7 ---

One word changed, in one place, and it is in the signature rather than at either call site. That is the second half of the design: a parameter declared borrowed is borrowed for you at the call site. You write announce(label), not announce(borrowed label). The signature still says which it is, so a reader keeps the information and only the noise goes. You write borrowed yourself where the position is not a call site — constructing a record that holds a borrow, for instance, or binding one directly.

Shared and exclusive

borrowed T is a shared borrow: read-only, and any number may coexist. mutable borrowed T is exclusive: it may write, and while it is alive nothing else may touch the value at all. Assignment through a borrow is written plainly, because Science has no dereference operator — r.celsius be r.celsius + 1i64 reads and writes through the borrow with nothing written for it.

type Reading:
    celsius: I64

def warm(r: mutable borrowed Reading):
    r.celsius be r.celsius + 1i64

def report(r: borrowed Reading) -> I64:
    r.celsius

let mutable r be Reading(celsius: 21i64)
warm(r)
warm(r)
print(f"{report(r)}")
# 23

The error that settles the whole bet

Rule 4 is the one that is hard to get right without annotations, so it is the one worth watching the compiler enforce. This program takes a shared borrow of r, then writes through r itself while that borrow is still going to be used.

type Reading:
    celsius: I64

let mutable r be Reading(celsius: 21i64)
let snapshot be borrowed r
r.celsius be 22i64
print(f"{snapshot.celsius}")
error[SC0330]: `r` is borrowed here and modified before the borrow ends
 --> overlap.science:6:14
  |
5 | let snapshot be borrowed r
  |                 ---------- `r` is borrowed here, shared
6 | r.celsius be 22i64
  |              ^^^^^ ...and modified here, which needs it exclusively
7 | print(f"{snapshot.celsius}")
  |       --------------------- the first borrow is still needed here
  |
  = note: a shared borrow and an exclusive one cannot overlap (§6.1 rule 4)
  = note: the borrow ends at its last use, so moving the last use above the conflicting line is enough; binding what is needed before it is the other fix
1 error

Read this one line by line, because it is the whole argument.

  • Line 5 is where the borrow is born. Not where r is declared — where this particular borrow starts. Nothing in the source says so; the compiler worked it out.
  • Line 6 is the violation, and the caret is on the write, not on a second borrow. Writing a place something else holds shared is what rule 4 forbids, and the message names which kind of access each side needed.
  • Line 7 is the load-bearing one. "The first borrow is still needed here" is why line 6 is an error at all. A borrow ends at its last use, not at the end of the block, so the compiler had to find that last use before it could know the two overlapped. Delete line 7 and the program compiles.
  • The fix is a reordering, and the compiler says which one. Move the last use above the conflicting line. That is not generated from a template; it is a consequence of the same analysis that produced the third span.
type Reading:
    celsius: I64

let mutable r be Reading(celsius: 21i64)
let snapshot be borrowed r
print(f"{snapshot.celsius}")
r.celsius be 22i64
print(f"{r.celsius}")
# 21
# 22

Now count the annotations in everything above: there are none. No 'a, no <'a, 'b>, no where 'b: 'a. Three spans across three lines, a rule citation and a mechanical fix — and the only thing you wrote was the word borrowed in a signature. That is the claim the language is built on, and this diagnostic is where you can check it rather than take it.

Where Java and Python are better, and for most programs they are

Python

snapshot = reading
reading.celsius = 22
print(snapshot.celsius)
# prints 22. No error, ever.
# The GC decides when to free.

Science

let snapshot be borrowed r
r.celsius be 22i64
print(f"{snapshot.celsius}")
# SC0330. Reorder, or copy.
# Freed at a point you can name.

A garbage collector is the better answer for most programs, and this page will not pretend otherwise. You never write borrowed, never reorder two lines to satisfy a checker, never read SC0330, and nothing you write can use memory after it is freed. The cost is a pause you cannot schedule and a heap you cannot predict, and for an enormous amount of software neither of those costs anything at all.

They cost something when you are holding a forty-gigabyte array on a node you are billed for by the second, which is the situation this language is designed for. That is a narrow claim and it is the honest one. Notice also what the Python column is not doing: it is not copying. Both languages hand you a second name for the same object here and both print through it. The only difference is that one of them checks that the first name has not moved out from under you.

8 · Absence and failure#

Two different problems, deliberately given two different shapes. A value that might not be there has a nullable type, T?. An operation that might fail returns what it produced and what went wrong, as a pair. There is no exception, no try, and no stack unwinding.

Absence: T? and the postfix ?

Int? is a type whose values are the Ints and null. There is nothing to wrap and nothing to unwrap. The one operation it has is the postfix ?, which asks whether a value is there and evaluates to a Bool.

let mutable measured: Int? be null
print(f"{measured?}")
measured be 42
print(f"{measured?}")
# false
# true

? produces a Bool and never the value, which is why that prints false and true rather than null and 42. You reach the value by narrowing: inside if measured?: the compiler knows measured is not null, so you write measured and use it as an Int, with no cast and no unwrap.

Failure: the (T, Error?) pair

A function that can fail returns two things: the value it produced, and an error that is null when nothing went wrong. The caller unpacks both with one let, tests the error with ?, and handles it before touching the value.

choice PortError:
    Missing
    NotANumber(Int)

def parse_port(text: borrowed String) -> (Int, PortError?):
    if text.is_empty():
        return (0, PortError.Missing)
    if text is not "8080":
        return (0, PortError.NotANumber(text.length()))
    (8080, null)

def explain(err: PortError) -> String:
    match err:
        Missing: "no port was given"
        NotANumber(n): "the port is not a number"

let port, err be parse_port("")
if err?:
    print(explain(err))
else:
    print(f"{port}")

That listing checks and does not build — the fourth of six

It exits 0 from check, exhaustiveness test and narrowing and all. build stops at the first String method: "cannot build a call to is_empty, which this crate was given no MIR body for". Write error handling this way; you cannot run it yet.

Three things happen in the last four lines and all three are worth naming. The unpack — let port, err be f() — is the one place destructuring exists in the language, and it exists because this is the shape every fallible call takes. The test is if err?:, the same postfix ? the nullable used, because an error that did not happen is an absent value. The narrowing means that inside the branch err is a PortError and not a PortError?, so it goes straight into explain with nothing written for it.

Forgetting to check is an error, not a habit

The obvious failure of a returned-error model is that you can ignore the error; Go's most-cited flaw is exactly this. Science makes it a compile error, and gives you a way to say you meant it.

choice PortError:
    Missing
    NotANumber(Int)

def parse_port(text: borrowed String) -> (Int, PortError?):
    if text.is_empty():
        return (0, PortError.Missing)
    (8080, null)

let port, err be parse_port("")
print(f"{port}")
error[SC0140]: `err` is never checked for an error
  --> unchecked.science:10:11
   |
10 | let port, err be parse_port("")
   |           ^^^ bound here and never tested
   |
   = note: test it with `if err?:`, pass it on, or return it — or rename it to `_err` to say the failure is deliberately ignored
1 error

A leading underscore is how you say the failure is deliberately dropped. That is a word you have to type, in the file, where a reviewer will see it.

Why there is no try

There was one, in an earlier revision, and it was removed along with Result and Option. The short version: the error path is now written where it happens, in the order it happens, for a reader rather than for a writer. The cost is that three fallible calls are nine lines rather than three.

Rust's ? is better at this, and the design notes say so outright — it composes, and Result makes ignoring a failure a type error rather than a separate check like SC0140. The full argument, with the same function written in five languages and a paragraph on what each of them gets right, is in the rationale's error model. It is not repeated here: that is an argument about a decision, and this is a page about writing the result.

Where Rust is better at absence, too

Rust

let port = config.get("port")
    .and_then(|s| s.parse().ok())
    .filter(|p| *p > 1024)
    .unwrap_or(8080);

Science

let mutable port: Int be 8080
let raw be config.get("port")
if raw?:
    let parsed be to_port(raw)
    if parsed?:
        if parsed > 1024:
            port be parsed

Option<T> is an ordinary type with a method set, so a chain of optional steps is a chain. T? is not a type you can call methods on: it has ? and nothing else — no is_some, no unwrap, no unwrap_or, no map — because there is no wrapper type left for those methods to hang off. Four optional steps in Rust are four lines; in Science they are four nested ifs, and the nesting is real.

What it buys is that T? costs nothing at a boundary: a field, a parameter and a return may all be nullable and none of them wraps anything. Whether that is the better trade depends on how much of your code chains optionals, and in numerical code the answer is usually "very little".

9 · Interfaces and generics#

An interface names a set of methods. A type declares that it provides them in a separate block, written Type implements Interface: — the reverse of Rust's impl Trait for Type, and it reads as a sentence about the type. A generic function takes a type parameter with of and constrains it with an interface.

interface and implements

An interface declares method signatures with no bodies. It may also supply a default body, which an implementation inherits unless it overrides it. The implementation block supplies the rest.

interface Volume:
    def litres(self) -> F64

type Flask:
    millilitres: F64

type Drum:
    litres_held: F64

Flask implements Volume:
    def litres(self) -> F64:
        self.millilitres / 1000.0

Drum implements Volume:
    def litres(self) -> F64:
        self.litres_held

let f be Flask(millilitres: 250.0)
let d be Drum(litres_held: 200.0)
print(f"{f.litres()} {d.litres()}")

That listing checks and does not build — the fifth of six, and for the same reason the method block in chapter 5 does not: the call is a method call, and the backend cannot yet resolve the receiver. The declarations, the two implementations and the coherence checking all pass check.

An interface with no methods at all is a marker, implemented on one line with no block: Position implements Copy. The library interfaces you will meet first are Copy, Clone, Eq, Ord, Display (what print requires), Drop, and the operator interfaces — Add, Sub, Mul and the rest — which is how a user type takes part in arithmetic.

Generics: of T, bounds, and where

def name of T(…) declares a function generic over T. A bound goes after the parameter with a colon, or in a where clause at the end when the signature gets long. Two or more type parameters take parentheses, because the comma would otherwise be ambiguous inside a parameter list: def swap of (A, B)(…).

interface Volume:
    def litres(self) -> F64

type Flask:
    millilitres: F64

Flask implements Volume:
    def litres(self) -> F64:
        self.millilitres / 1000.0

def fuller of T: Volume(a: borrowed T, b: borrowed T) -> F64:
    if a.litres() > b.litres(): a.litres() else: b.litres()

def total of T(a: borrowed T, b: borrowed T) -> F64 where T: Volume:
    a.litres() + b.litres()

let small be Flask(millilitres: 250.0)
let large be Flask(millilitres: 750.0)
print(f"{fuller(small, large)} {total(small, large)}")

That listing checks and does not build — the last of six

Generics are monomorphised: one specialised copy of the function for each set of type arguments it is called with. Nothing runs that walk yet, so a call to a generic function is refused — "cannot build a call to the generic function fuller, which nothing has monomorphised". The same applies to an interface's default method body, whose self is a different concrete type in every implementation.

of is also how a generic type is written: Array of Doc, Map of (String, Int), Array of (Map of (String, Int)). One argument needs no parentheses and two or more do, and nesting follows the same rule. Naming a generic type before a . needs its own parentheses too — (Array of Doc).new(), never Array of Doc.new(), because the second does not say whether .new belongs to Doc or to the whole type.

Where Java and TypeScript are better: <T> never needs parentheses

TypeScript

function swap<A, B>(p: Pair<A, B>): Pair<B, A>

type Grid = Map<string, Array<number>>;

Science

def swap of (A, B)(p: Pair of (A, B)) -> Pair of (B, A)

type Grid is Map of (String, Array of F64)

Angle brackets are self-delimiting and of is not. <A, B> is unambiguous with one argument or six, at any nesting depth, and anyone who has written Java, C#, TypeScript or Rust already reads it without thinking. of needs parentheses the moment there are two arguments, and again the moment you nest, and that is a rule you have to remember rather than a shape you can see. For this one construct the familiar notation is simply better.

What of buys is elsewhere in the grammar: < and > stay unambiguously comparison operators, so the parser never needs the disambiguation pass that C++ and Java both carry. That is a real benefit to the compiler and a mild cost to you, and which side you land on probably depends on how often you write six-parameter generics.

10 · Talking to C#

Every floating-point routine worth using is already written in C. Science calls them directly — no binding generator, no wrapper library, no marshalling layer. An extern "C" block declares what a library provides, and an unsafe block is where you are allowed to call it.

The block, and what it claims

unsafe extern "C" library "m": opens a block of declarations for the C math library. Inside it you write signatures with no bodies, in Science's own type vocabulary. Nothing in that block is checked against anything. It is a claim about what is in the library, and if the claim is wrong the program is wrong in a way no compiler will catch — which is exactly why the block says unsafe out loud, and why reaching into it takes a second unsafe at the call site.

unsafe extern "C" library "m":
    def sqrt(x: F64) -> F64
    def cos(x: F64) -> F64

def hypotenuse(a: F64, b: F64) -> F64:
    unsafe:
        sqrt(a * a + b * b)

let straight be unsafe:
    cos(0.0)

print(f"{hypotenuse(3.0, 4.0)}")
print(f"{straight}")
# 5.0
# 1.0

That program compiles to a native executable, links against libm, and prints those two numbers. It is the whole of the FFI story for a function taking and returning a scalar. An unsafe block is an expression, so it can be a function's body or the right-hand side of a binding — but it cannot go inside an f"…", where the : is read as the start of a format specification.

The wrapper is the interface, and the unsafe block is short

Look at where unsafe sits in hypotenuse: around the call, and nothing else. The arithmetic is outside it. That is the discipline the whole design rests on — if an unsafe block is long, the binding is written wrong — and it is why the thing you export from a module is never the extern declaration.

A real binding has three layers. The extern block, which nothing outside the module calls. A safe Science function whose signature contains no foreign type at all, which checks what the C function assumes — that the shapes match, that a length fits, that a handle is open — and returns an ordinary Science error if they do not. And the unsafe block inside it, which is the two or three lines that actually cross the boundary. Callers get the middle layer and never see the other two.

The vocabulary for the harder cases — ffi.Span of F64 for a pointer and a length, ffi.CStr, ffi.Complex64, ffi.OpaqueHandle, symbol "…" to decouple the Science name from the linker name, via pkg-config, kind static, and when available for a library that may not be installed — is in the reference's foreign function interface section, and demonstrated across a full OpenBLAS and HDF5 binding in examples/20_extern.science.

Where Python is better, and for exploratory work it is not close

Python

import numpy as np
np.linalg.norm([3.0, 4.0])
# 5.0
#
# no compiler, no declaration,
# no rebuild, no `unsafe`

Science

unsafe extern "C" library "m":
    def sqrt(x: F64) -> F64

def hypotenuse(a: F64, b: F64) -> F64:
    unsafe:
        sqrt(a * a + b * b)

Every BLAS and LAPACK routine you were going to bind is already bound for you in NumPy and SciPy, and has been for twenty years. You import it, you call it, and you are wrong about a shape at runtime instead of at compile time — which, during exploration, is the better place to be wrong. There is no build step to wait for and no declaration to keep in sync with a header that changed.

Science's version is more work and it buys two things. The wrapper's checks — the shape test, the leading-dimension test — run as ordinary Science errors rather than as a segfault three calls later. And there is no interpreter between the loop and the library, which is the difference that matters when the loop is the program. If you are exploring, use Python. If you are shipping the kernel, this is what it costs.

Putting it together

Here is a program using nearly everything in this tutorial: a record, a choice, two matches, three shared borrows, interpolation, and a real call into libm. It builds and runs.

unsafe extern "C" library "m":
    def sqrt(x: F64) -> F64

type Reading:
    sample: I64
    celsius: F64

choice Station:
    Ground
    Balloon(F64)
    Buoy(F64, F64)

def kelvin(r: borrowed Reading) -> F64:
    r.celsius + 273.15

def altitude(station: Station) -> F64:
    match station:
        Ground: 0.0
        Balloon(metres): metres
        Buoy(latitude, longitude): 0.0

def spread(low: borrowed Reading, high: borrowed Reading) -> F64:
    let difference be high.celsius - low.celsius
    unsafe:
        sqrt(difference * difference)

def label_for(station: Station) -> String:
    match station:
        Ground: "ground"
        Balloon(metres): "balloon"
        Buoy(latitude, longitude): "buoy"

let low be Reading(sample: 1i64, celsius: 18.0)
let high be Reading(sample: 2i64, celsius: 21.5)

print(f"sample {low.sample}: {low.celsius} C = {kelvin(low)} K")
print(f"sample {high.sample}: {high.celsius} C = {kelvin(high)} K")
print(f"spread {spread(low, high)} C")
print(f"{label_for(Balloon(1200.0))} at {altitude(Balloon(1200.0))} m")
# sample 1: 18.0 C = 291.15 K
# sample 2: 21.5 C = 294.65 K
# spread 3.5 C
# balloon at 1200.0 m

11 · Where to go next#

You can now read and write Science. What this page deliberately left out is depth, inventory and argument — one page each.

The three pages, and what each is for

The other three things worth your time

examples/ in the repository holds twenty-two programs, one per feature, that are guaranteed to lex and parse — they are input to the test suite, so a change that breaks one is a bug in the change rather than a typo in the corpus. If you are unsure how something is written, look there first. examples/README.md says what each one covers.

tests/ui/ is the opposite corpus: programs that must fail, each paired with the exact diagnostic it has to produce, byte for byte. It is the fastest way to find out what the compiler says about a mistake before you make it.

The status section on the home page lists what has been run and what has not, with the command that settles each claim. The boundary between check and build moves; that section, and the compiler's own SC0400 note, are the two places that track it — and the compiler is always the more current of the two.

The exercise this page is set up for

Take the program at the end of chapter 10 and extend it. Add a Sonde(F64, F64) variant to Station and let the compiler tell you every match that now has a hole. Add a warmer_than(a: borrowed Reading, b: borrowed Reading) -> Bool. Give spread a PortError-style pair return for the case where the two readings are from different stations, and watch SC0140 catch you the first time you forget to check it. The first two still build and run; the third checks and does not, and it is the one that teaches you the most.