Syntax · every construct, every word

What you can write

This page answers one question: is this expressible in Science, and how is it spelled? Every construct the language has, with its shape and a program that compiles. Every word the lexer knows, with whether it means something today. The precedence table read off the parser rather than off a note. And — because a reference that omits this is the one that costs a reader an afternoon — the constructs that parse and are not built, named with the diagnostic each one currently produces.

The reference explains what a construct means and why the language has it. The rationale argues for the spelling against the ones it beat. This page is the inventory: shorter on argument, complete on coverage.

Words the lexer knows 68 — 40 live, 28 held Science listings 38 — 36 clean, 1 a deliberate error, 1 removed syntax Of those, built and run 10, output matched Parses and unbuilt 6 constructs Compiler sciencec 0.0.1

How every listing here was verified, and what the verdict means

Thirty-seven Science listings below were extracted from this file's own source, written to scratch files and run through sciencec check. Thirty-six report nothing. The thirty-seventh is the IndexMutably block in Parses, and is not built, which is on this page in order to fail and whose diagnostic is printed beneath it. One further block shows removed syntax, carries a grey rail and a caption, and is not valid Science, so it is not one of the thirty-seven. One listing — modules — needs a sibling file beside it, which is written out under the block, and it checks clean with that file in place.

Ten listings carry a trailing # comment holding their real stdout, compiled with sciencec build and executed, and every one of those ten matched. They were not all run in the same pass: a listing keeps its output comment only for as long as somebody has seen the program print it, and one is removed rather than guessed at the moment it cannot be reproduced.

A listing with no output comment was checked and not run, and that is the majority of them, usually because it uses a construct the LLVM backend refuses: the backend implements the first four lowering stages, so a method call, an Array, a generic, a loop, integer / and %, and a shift all check and none of them build. That distinction is stated rather than blurred, because check accepts a great deal more of this language than build emits. check needs no backend at all, which is why it is the measurement this page rests on.

Every diagnostic reproduced on this page is the compiler's own stderr, run and pasted, down to the span underlines. Where a construct is specified and unbuilt it is in Parses, and is not built with the error it produces — never in a section above as if it worked. Nothing on this page is syntax somebody wished for.

That list shrinks, and when it does the entry says so rather than vanishing. Two of the six moved while this page was being extended. The array literal, which used to parse and never be type checked, is checked now: SC0281 and SC0282 exist, both of its listings stopped checking clean, and what was item 3 is a note inside its replacement. A range index, which used to check clean and quietly hand back one element, is now SC0538 and says what it means. A reference that deleted those two paragraphs would leave a reader no way to judge how stale the other four are.

The shape of a file#

A file is a module. Its top-level statements are its program, so the smallest whole Science program is one line and there is no main to write. Blocks are indentation, opened by a :. Comments are # to end of line; ## is a doc comment and attaches to the declaration below it. The lexical rules are in Tokens, identifiers, whitespace, the three kinds of comment in Comments, and the block and continuation rules in Indentation, newlines, continuation.

print("lift-off")
# lift-off
$ sciencec check hello.science
$ echo $?
0
$ sciencec build hello.science
$ ./hello
lift-off

check printing nothing is the verdict. Diagnostics go to stderr and dumps to stdout, and the exit code is 0 when nothing was reported as an error — warnings alone do not fail.

The seven statement forms

FormWrittenNotes
bindinglet x be vlet mutable to allow reassignment.
assignmentx be vThe same word. Assignment is a statement, never an operator.
expressionf(x)A bare expression on its own line.
returnreturn vOr bare return from a function with no value.
breakbreakEnds the innermost loop or for.
continuecontinueNext iteration of the innermost loop.
declarationdef …Every form in the four sections below.

A function body's last expression is its value; there is no implicit return anywhere else. A file may have top-level statements or a main, and not both — two answers to "what runs first" is SC0117, and what a top-level statement binds is in Scope and shadowing.

Seven, and const is not one of them. const NAME be v is a declaration, so it lives at the top level of a module and not in a body: writing one inside a def is SC0105, "expected an expression, found const". That is the rule that makes a constant visible to every item in the file, which a statement could never be.

Tokens, identifiers, whitespace#

A source file is UTF-8 text and nothing else — no byte-order-mark rule, no encoding pragma, no include. The lexer turns it into a stream that always ends in Eof, and it recovers from every error rather than stopping, so a stray character costs one diagnostic and the rest of the file is still read. Four of the token kinds are synthetic: they stand for line structure, they have no characters of their own, and they are what makes indentation a grammar rather than a convention.

Every token kind

KindWrittenNotes
Newlinethe end of a logical lineNot emitted inside an unclosed bracket, and not emitted for a line that produced no token of its own.
Indenta line indented past the level above itOne, whatever the width of the step.
Dedenta line indented backOne per level closed. A line that closes four blocks emits four.
Eofthe end of the filePreceded by every outstanding Dedent, so a file that ends mid-block still hands the parser a balanced stream.
Int42   0xFF   0o755   0b1010Carries value, base and suffix. The _ separators and the base prefix are already gone.
Float3.14   6.02e23Carries value and suffix.
Str"hola"   r"C:\runs"Escapes already resolved. A raw literal carries no marker into the stream: nothing after the lexer needs to know how a String was spelled.
Char'a'Exactly one Unicode scalar value.
FStrStart, FStrText, InterpStart, InterpEnd, FStrEndf"a{x}b"An f"…" is five kinds of token, not one, and a hole's interior is ordinary tokens in the same stream. See Literals.
Identsamples   dia   n2Anything word-shaped that is not one of the 68 words.
keyword68 distinct words40 live, 28 held. The list is in Every reserved word.
punctuation( ) [ ] { } , : ; . .. ..= -> => _ @ ?Longest match wins: ..= before .. before ., and -> before -.
operator+ - * ** / % & | ^ << >> = == != < > <= >=Rungs and associativity are in Operators and precedence.
Unknownanything elseEmitted rather than aborting, which is what lets the parser report more than one error per pass.

Four of those are lexed and the grammar consumes none of them. ; and => have token kinds and no production: let a be 1; let b be 2 is SC0100, "expected end of line, found ;". { and } are lexed and reach only the parser's bracket-depth counters, which is error recovery and not syntax. A fifth kind, Hash, is never emitted at all — it exists against the day # stops meaning a comment. A page that listed them among the syntax would be describing the token table instead of the language, so they are named here and nowhere else.

What may be an identifier

An identifier starts with _ or any character Unicode calls alphabetic, and continues with _ or any alphanumeric one. That is the whole rule, and it is why a Greek letter is an ordinary name rather than a concession — which matters for a language whose programs are meant to be read beside the paper they came from. Case is significant and carries no meaning of its own: Doc and doc are two names, and nothing in the grammar requires a type to be capitalised.

let snake_case be 1
let CamelCase be 2
let n2 be 3
let _unused be 4
let día be 5
let Δt be 0.5
print(f"{snake_case} {CamelCase} {n2} {día} {Δt}")
# 1 2 3 5 0.5
Not an identifierWhat happens
2nA digit starts a number, so this is the literal 2 carrying the suffix n: SC0009, and then SC0102 for the name that never arrived.
kebab-caseThree tokens. - is subtraction and begins no identifier.
total$SC0001 — $ is not a character Science recognises. There are no sigils of any kind.
_Its own token, the wildcard. let _ be 1 is SC0102: _ names nothing, so there is nothing to bind. _x, with anything after the underscore, is an ordinary name.

What is whitespace

Between tokens a space, a tab and a carriage return separate and mean nothing else, so a file indented with spaces and aligned with tabs inside a line is one program however it is displayed, and a CRLF file is the same program as an LF one. In the indentation the rule is different and stricter: only spaces count, a tab there is SC0003, and a lone carriage return before the break carries no width. The reason is in the diagnostic — a tab's width depends on how the file is displayed, so a block indented with one would mean different things to two readers of the same file. The cost is that the same character is legal in one column and an error three columns to its left.

The language sets no line length. sciencec fmt targets 90 columns and indents by four spaces, and neither number is a rule the compiler enforces.

Every lexical diagnostic

SC0001–SC0099 is the lexer's band and it spends twelve of them. SC0170–SC0177 is the interpolating literal's band, emitted here as well, because the shape of an f"…" is only visible to the pass that reads the characters: by the time the parser has a token stream, an unterminated hole has already become a stream that stops.

CodeFires onExample
SC0001A character that is not part of the language.1 $ 2
SC0003A tab in a line's indentation.a tab, then code
SC0004An indentation width matching no open level.4, then 2
SC0005An integer literal too large to hold.999…999
SC0006An escape the lexer does not know."\q"
SC0007A string with no closing quote on its line."open
SC0008A character literal unterminated, empty, or holding more than one.'ab'
SC0009A suffix that is not one of the ten, or an integer suffix on a float.42foo   1.5i32
SC0010A digit outside the literal's base.0b1012
SC0011A float literal that overflows F64.1e400
SC0016==. Fixed to is.a == b
SC0017!=. Fixed to is not.a != b
SC0170An interpolation with no closing }.f"{n"
SC0171A } inside an f"…" that closes nothing.f"a } b"
SC0173A format specification, which is specified and unbuilt.f"{pi:.2f}"
SC0174An interpolation with nothing in it.f"{}"
SC0175A # inside an interpolation — it would comment out the rest of the literal.f"{n # why}"
SC0177Literal prefixes combined.rf"x"

SC0002 and SC0012–SC0015 are unallocated. SC0172 — a plain "…" every one of whose names resolves, which is a forgotten f — is specified and not implemented, and cannot be: deciding it needs a scope, and the lexer is the only pass that still has the literal.

error[SC0016]: equality is written `is`
 --> stale.science:1:12
  |
1 | let a be 1 == 2
  |            ^^ `==` 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

== is a lexical error and not a parse error, and the difference is the point: the lexer reports it and then emits the equality token anyway, so a stale symbol costs one diagnostic carrying an applicable fix, and the rest of the expression parses as though the fix had already been taken. ! on its own is SC0001 — negation is not, and ! begins no operator at all.

Comments, documentation, pragmas#

One character opens all three kinds and what follows it decides which: # is an ordinary comment, ## is documentation that the compiler reads and ships, and # fmt: off is an instruction to the formatter. All three run to the end of the line. There are no block comments, and there is no nesting to get wrong, because a comment ends where the line does and nothing reopens it.

# An ordinary comment runs to the end of the line. There are no block
# comments, so a paragraph is a run of these.

#No space after the hash is required.
# A comment may hold a "string", a 'c', an 0xFF, and words that would be
# keywords anywhere else: def return let be mutable type choice giving.
# It may hold anything Unicode can spell.

def tally(n: Int) -> Int:
    # A comment as the first thing in a block.
    let mutable total be n  # a trailing comment after a statement

    # A comment-only line inside a block, after a blank one.

    total be total + 1
    total

print(f"{tally(1)}")
## Counts the samples above a floor.
##
## The first line is the summary. The whole run is the description, summary
## included, and `sciencec tools --json` emits it verbatim.
tool count_above(samples: Array of F64, floor: U16) -> Int:
    samples.length()

## A run attaches to the next token that has text of its own, so the newline
## and the indent between this run and `def` pass straight over it.
def unused() -> Int:
    0

print(f"{unused()}")
# 0

# — the ordinary comment

Everything from the # to the line break, and nothing else. A comment-only line and a blank line take no part in the indentation calculation, which is what lets a comment sit at any column without opening or closing a block — a comment written at column 0 in the middle of a function does not end the function, and one indented past every open level does not start one. That rule is the reason the language can afford to be indentation-delimited and still let you write a note wherever the note belongs.

## — the documentation run

A doc comment is trivia, not a token. Making it a token would put it in every match over token kinds in the parser, for a thing the grammar never mentions; instead the lexer accumulates a run of consecutive ## lines and hands it to the next token that has text of its own. Newline, Indent and Dedent pass over it without consuming it, and that is precisely what lets a run survive the line break and the indent that always sit between it and the declaration it documents.

RuleWhat it means
##Documentation. So is ###, and every deeper run — ### heads a Markdown section, and refusing it would make the marker fight the language written inside it. The cost is that a #### rule drawn as decoration is a doc line, and joins the run below it.
## textThe marker and one following space are stripped. Any further indentation is yours and is kept, because a doc comment holds code samples.
a runConsecutive ## lines are one thing with one span, from the first # to the last non-blank character. Trailing whitespace is dropped so a caret under the run is as wide as the run reads.
where it may goBefore any declaration, and before a tool's parameters. Nowhere else — a run before a def's parameter is SC0194.

On a tool the run is not documentation the compiler tolerates; it is program data, and the compiler enforces it. The whole run — every line, joined by line feeds, summary included — becomes the description that a model reads in order to decide whether to call the thing.

$ sciencec tools --json count.science
[{"name":"count_above","inputSchema":{"type":"object","properties":{"samples":
{"type":"array","items":{"type":"number"}},"floor":{"type":"integer","minimum":0,
"maximum":65535}},"required":["samples","floor"]},"description":"Counts the
samples above a floor.\n\nThe first line is the summary. The whole run is the
description, summary\nincluded, and `sciencec tools --json` emits it verbatim."}]

That is one line in the real output, wrapped here to fit the column. Note what is not in it: there is no title field. The first line is the summary by rule — SC0195 enforces that it is not blank — and the split is never made in the JSON, because the caller reads the description whole.

CodeFires on
SC0190A tool with no ## run above it. An undescribed tool does not fail: it is simply never chosen, and no test catches that.
SC0194A ## run before the parameter of anything that is not a tool. Only a tool documents its parameters one by one, because only there does each become a described field of a schema.
SC0195A tool's run that opens with a blank ##. The caret covers the whole run, not the declaration under it, because a run of four lines that opens blank is wrong across all four.
error[SC0194]: only a `tool` documents its parameters one by one
 --> docs.science:2:5
  |
2 |     ## a doc run on a parameter
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^ this `##` comment documents a parameter
3 |     x: Int
  |     - the parameter it is attached to
  |
  = note: move the text into the `##` comment above the declaration; a `tool` documents a parameter here because each one becomes a described field of the schema its caller reads
1 error

Two gaps, stated rather than implied. SC0176 — a ## run attached to no declaration, at the end of a file or separated from the next declaration by a blank line — is specified as a warning and is defined in no crate: today such a run is silently dropped, and a run separated from its declaration by a blank line still attaches to it. And a ## run on a tool's parameter is parsed and kept in the syntax tree, but the schema emitter works over a later representation that has no field to carry it, so no per-property description reaches the JSON.

# fmt: — the formatter's three pragmas

sciencec fmt is idempotent and has one canonical layout, so the only thing it can offer is an exception. There are three, and they are ordinary comments to every other pass — the compiler does not know they exist.

WrittenDoes
# fmt: offSuppresses formatting from this line until the matching # fmt: on. Both marker lines are inside the region and are reproduced.
# fmt: onEnds the region. On its own, with no off above it, it is an ordinary comment and is never warned about.
# fmt: skipSuppresses the next statement — the next logical line, however many physical lines that spans, along with anything written between the marker and it.
$ cat held.science
# fmt: off
let table be 1
let x     be 2
# fmt: on
let y     be 3
$ sciencec fmt held.science
# fmt: off
let table be 1
let x     be 2
# fmt: on
let y be 3

The spelling is exact and the compiler will not tell you when you miss it. Exactly one #; then, after any amount of whitespace, the literal text fmt: off, fmt: on or fmt: skip, and nothing else on the line. So #fmt: off works and # fmt:off does not; ## fmt: off is a doc run and never a directive; # FMT: OFF is a comment; # fmt: off for now is a comment. There is deliberately no diagnostic for a near miss, which is the cost: a mistyped marker is silently inert.

Also inertWhy
f(a) # fmt: skipA marker must stand on a line of its own between statements. Trailing on a line of code it belongs to that line, and is an ordinary comment — which is the opposite of what the same spelling does in Black.
f(
  # fmt: off
)
A marker inside a bracketed list is an ordinary comment. Inside brackets there are no statements to stand between.
off … off … onRegions do not nest. A marker inside a region is text: the scan takes the first on it finds, so the inner off opens nothing.

An off with no on is the one case that is reported. The region runs to the end of the file and the formatter warns rather than refusing, because ignoring the marker would reformat code that asked to be left alone — the one thing the marker exists to prevent.

warning[SC0901]: `# fmt: off` has no matching `# fmt: on`
 --> held.science:1:1
  |
1 | # fmt: off
  | ^^^^^^^^^^ formatting is suppressed from here to the end of the file
  |
  = note: the forgiving reading was chosen deliberately: ignoring the marker would reformat code that asked to be left alone, which is the one thing the marker exists to prevent. Write `# fmt: on` where the exception ends.
1 warning

A suppressed region is reproduced as written, but the whole of it is shifted by one common offset so that its first line lands on the column the rest of the file uses. Byte-for-byte reproduction was rejected for a reason worth knowing: a region left at a column the file no longer uses would change the block structure, and a formatter that can change what a program means is worse than one that reformats something you wanted held.

Indentation, newlines, continuation#

Indentation is how every block in this language is delimited. There is no {, no end, no begin. A : at the end of a line opens a block; indenting past the level above emits Indent; indenting back emits one Dedent per level closed. Because the lexer does this, a block is a grammatical object the parser matches on, and a misplaced line is a diagnostic with a caret rather than a program that quietly means something else.

The rules, in full

RuleWhat follows from it
Spaces only.A tab in the indentation is SC0003. Recovery gives the line the level currently on top of the stack rather than guessing a width, so one tab costs one diagnostic and not a cascade.
Any width opens a level.The language names no number. Four spaces is what sciencec fmt writes and not what the compiler requires.
Dedenting must land on an open level.Otherwise SC0004, which lists the levels that are open. Recovery pushes the width as a new level so the stream stays balanced.
One Dedent per level closed.A statement at level 6 followed by one at level 1 emits five. Closing many levels at once is the case that breaks a naive indentation stack, so the corpus goes eight deep on purpose.
Blank and comment-only lines take no part.They are skipped before the width is measured, so neither opens or closes anything, whatever column it is written at.
The file ends the last line and closes every block.A file with no trailing newline still gets its Newline, and Eof is preceded by every outstanding Dedent. The parser sees one shape and not two.

The synthetic tokens are visible. sciencec tokens prints the stream, which is the shortest way to settle any argument about what a layout means:

$ cat sign.science
def sign(n: Int) -> Int:
    if n < 0:
        -1
    else:
        1
$ sciencec tokens sign.science
   0..3     Function
   4..8     Ident("sign")
  ...
  23..24    Colon
  24..25    Newline
  25..29    Indent
  29..31    If
  ...
  37..38    Colon
  38..39    Newline
  39..47    Indent
  47..48    Minus
  48..49    Int { value: 1, base: Dec, suffix: None }
  49..50    Newline
  54..54    Dedent
  54..58    Else
  58..59    Colon
  59..60    Newline
  60..68    Indent
  68..69    Int { value: 1, base: Dec, suffix: None }
  69..70    Newline
  70..70    Dedent
  70..70    Dedent
  70..70    Eof

Note the Dedent before Else, and the two at the end. Indent carries a span covering the whitespace it measured; Dedent and the final Newline are zero-width, which is why a diagnostic about a missing block points at a column rather than underlining one.

error[SC0004]: this indentation matches no open level
 --> ragged.science:3:1
  |
3 |   let b be 2
  | ^^ indented 2 columns
  |
  = note: the open levels are 0

error[SC0100]: unexpected indentation at the top level of a module
 --> ragged.science:3:3
  |
3 |   let b be 2
  |   ^ unexpected indentation at the top level of a module
2 errors

Line continuation — the two things that do it

Every line break ends a statement, except two, and both are written at the start of the continuing line. A reference that omits this is the reason a reader believes a five-line chain is five statements.

ContinuesRule
( [ {While any bracket is open the line structure is suppressed entirely: no Newline, no Indent, no Dedent, and the indentation inside is decorative. This is what makes one-argument-per-line formatting work.
.A line whose first character is . and whose next character can start an identifier continues the line above. That is how a method chain breaks.
whereA line beginning with the word where continues the line above, so a long signature breaks before its bounds. No statement may begin with where, so recognising it here costs nothing.

.. is excluded from the . rule deliberately: a range is not a chain link, and no line may begin with one. The rule is written as "a . followed by an identifier start", which also means a leading .0 continues nothing — consistent with there being no t.0 to reach for. Blank and comment-only lines take no part here either, so a comment in the middle of a chain does not break it.

type Config:
    host: String
    port: U16
    retries: U8

def connect(
    host: borrowed String,
    port: U16,
    retries: U8,
) -> Bool:
    retries > 0

def hosts(configs: borrowed Array of Config) -> Array of String:
    configs
        .iterate()
        # a comment-only line takes no part, and the chain survives it
        .map(each.host)
        .collect()

def described of T(
    value: borrowed T,
) -> String
        where T: Display:
    f"{value}"

let config be Config(
    host: "localhost",
    port: 8080u16,
    retries: 3u8,
)
print(f"{connect(config.host, config.port, config.retries)}")

Trailing commas are allowed in every parenthesised and bracketed list — arguments, parameters, generic arguments, record fields, tuples, array literals and pattern lists. They are not allowed in a where clause, which has no brackets to hold it open: where T: A,: is SC0104 and then a small cascade, because the parser goes looking for the type that the comma promised.

The two continuations are visible in the token stream as an absence. A chain broken over four lines produces one Newline, at the end of the last:

$ sciencec tokens chain.science
  ...
  59..60    Newline
  60..64    Indent
  64..66    Ident("xs")
  75..76    Dot
  76..83    Ident("iterate")
  83..84    LParen
  84..85    RParen
  94..95    Dot
  95..102   Ident("collect")
 102..103   LParen
 103..104   RParen
 104..105   Newline
 105..105   Dedent
 105..105   Eof

Between Ident("xs") at 64 and Dot at 75 there are two line breaks and eight columns of indentation, and not one token stands for them. That is the rule doing its job: an indented chain continuation is expression indentation, not a body, and an indentation-delimited language has to be able to tell them apart or it cannot have chains at all.

def and tool#

def declares every function, method, associated function and interface member the language has. There is one word for all four, and the thing that tells them apart is where the declaration sits and whether its first parameter is self.

The shape is def name(param: Type, …) -> ReturnType: and an indented body. The return arrow is omitted when there is nothing to return; the function's type is then (). Parameters are always annotated — inference is local to a body and stops at a signature, so a signature is the one place a type is never guessed. The body's last expression is its value.

def area(width: Int, height: Int) -> Int:
    width * height

def greet(name: borrowed String):
    print(f"hola, {name}")

def clamp(value: Int, low: Int, high: Int) -> Int:
    if value < low: return low
    if value > high: return high
    value

print(area(6, 7))
greet("Kepler")
print(clamp(120, 0, 100))

That listing checks and does not build: print of a non-String needs a vtable the backend does not emit. The same three constructs, with the values sent through an f"…" instead, do build:

def area(width: Int, height: Int) -> Int:
    width * height

let w be 6
let h be 7
print(f"{w} by {h} is {area(w, h)}")
# 6 by 7 is 42

tool — a function a model may call

tool is a second declaration form, not a synonym for def, and it exists because it carries five obligations a def does not and a compiler can check: every parameter type has to survive a round trip through JSON Schema, the declaration may not be generic, no parameter may be borrowed, it must carry a ## description, and its return type is constrained. A marker that changes nothing would be an attribute; five checkable obligations are a declaration form.

## Count the peaks above a threshold in one run.
##
## Returns the number of peaks, or zero when the run is empty.
tool count_peaks(run: String, threshold: Int) -> Int:
    if run.is_empty(): return 0
    run.length() + threshold

print(count_peaks("abc", 2))

The description is not documentation the compiler tolerates — it is an argument, and the schema below is derived from the signature rather than written beside it. An Int becomes an integer with its width, which is a claim no other tool SDK's int can make.

$ sciencec tools --json peaks.science
[{"name":"count_peaks","inputSchema":{"type":"object","properties":{"run":
{"type":"string"},"threshold":{"type":"integer","minimum":-9223372036854775808,
"maximum":9223372036854775807}},"required":["run","threshold"]},"description":
"Count the peaks above a threshold in one run.\n\nReturns the number of peaks,
or zero when the run is empty."}]

The JSON above is one line in the real output; it is wrapped here to fit the column. Nothing in a tool declaration names a protocol: it declares a name, a typed parameter list, a required description and a result, and MCP is one backend over that which lives in a package.

type and choice#

type Name: declares a record — named fields, all of them present. choice Name: declares a sum — named variants, exactly one of them present. type Name is T, with the is, declares an alias rather than a new type. The three forms share one keyword and are told apart by what follows the name.

type Reading:
    channel: Int
    counts: Int

choice Format:
    Plain
    Markdown
    Json

choice LoadError:
    NotFound(String)
    Malformed(String, Int)

type Embedding is Array of F32
type Counter is I64

let first be Reading(channel: 1, counts: 4_900)
print(first.counts)

A record is constructed with named arguments and nothing else: Reading(channel: 1, counts: 4_900). There is no positional form and no ordering rule to remember, which is also what lets a record pattern in a match read exactly like the construction it mirrors. A variant's payload is positional, because a variant is a tag over a tuple rather than a record.

type Reading:
    channel: Int
    counts: Int

def total(first: Reading, second: Reading) -> Int:
    first.counts + second.counts

let a be Reading(channel: 1, counts: 4_900)
let b be Reading(channel: 2, counts: 1_180)
print(f"channels {a.channel} and {b.channel} hold {total(a, b)} counts")
# channels 1 and 2 hold 6080 counts

A variant is reachable unqualified — Json — and also through its type — Format.Json. The qualified form is what disambiguates when two choices in scope share a variant name; both spellings are the same value and neither is preferred.

Interfaces and methods#

Three declaration forms share the job a class does elsewhere. interface I: declares required methods, default methods and associated types. T implements I: supplies them. T has: declares methods that come from no interface at all. Splitting them is what lets an interface be implemented for a type its author never saw.

interface Summarize:
    def summarize(self) -> String

    def preview(self) -> String:
        self.summarize().truncate(80)

type Doc:
    title: String
    body: String

Doc implements Summarize:
    def summarize(self) -> String:
        self.body.truncate(200)

Doc has:
    def new(title: String) -> Doc:
        Doc(title: title, body: String.new())

    def is_empty(self) -> Bool:
        self.body.is_empty()

choice Format:
    Plain
    Json

Format implements Copy

let doc be Doc(title: "Regions", body: "a region is a set of program points")
print(doc.preview())
print(Doc.new("scratch").is_empty())
FormMeansReached by
def m(self) -> TA method.value.m()
def m(mutable self) -> TA method that may write its receiver.value.m(), receiver borrowed exclusively
def m(x: T) -> UAn associated function: no receiver.Type.m(x)
type ItemAn associated type, named by the interface.Self.Item, supplied with type Item is Int
T implements IA marker interface — nothing to supply.One line, no body

Eighteen interfaces are in the prelude and eight of them are operators: Add, Sub, Mul, Div, Rem, Pow, MatMul and Neg, plus Index, IndexMutably, Eq, Ord, Copy, Clone, Drop, Iterate, From, Display and Error. A scientific language in which + does not work on your own type is not a scientific language, so + is a method call and a user type takes part in arithmetic.

Modules and visibility#

A file is a module and a directory with a mod.science is a module containing its siblings, so there is no declaration form for one and nothing to keep in sync with the filesystem. use imports; public exports; const declares a compile-time constant. Everything is private until it says otherwise.

use text.parser
use text.parser (Token, lex)

const WIDTH be 768
const GREETING be "hola"

public type Document:
    title: String
    body: String

public def parse(source: borrowed String) -> Array of Token:
    lex(source)

def helper(document: borrowed Document) -> Int:
    document.body.length()

public def main():
    print(WIDTH)
    print(GREETING)
    print(helper(Document(title: "t", body: "body")))

This listing needs a file beside it, and that is the one listing on this page that is not self-contained. The text/parser.science it was checked against is four lines: public type Token: with a text: String field, and public def lex(source: borrowed String) -> Array of Token returning (Array of Token).new(). check treats the file's own directory as the crate root and compiles every module the use declarations name.

FormMeans
use a.bImport the module; its names are reached through the path, a.b.f().
use a.b (X, y)Import selected names; they are used unqualified.
use a.b (
  X,
  y,
)
A selection over several lines, trailing comma permitted.
public …A prefix on a type, choice, interface, def or a member of a has block.
const NAME be vA compile-time constant. Uses be, like every other binding; has no storage of its own.

public is refused where it would mean nothing. On a use, on an extern block, on an implementation and on a top-level statement it is SC0107 with a deletion offered — an import exports nothing, an implementation declares no name to export, and a statement in a script has no name at all. There is one visibility word and no private: everything is private until it says otherwise, so the absent word is the default and the present one is the exception.

extern and unsafe#

An extern block is a list of symbols and the claims a programmer makes about them, in a small grammar of its own. It is always unsafe, because every line in it is something the compiler will never check. unsafe: is also a block expression, and it is the only place a foreign function may be called.

unsafe extern "C" library "m":
    def fabs(value: CDouble) -> CDouble
    def pow(base: CDouble, exponent: CDouble) -> CDouble

unsafe extern "C" library "openblas" via pkg-config "openblas":
    type BlasInt is I32
    const CBLAS_ROW_MAJOR be 101 as CInt

    def cblas_ddot(
        n: BlasInt,
        x: ffi.Span of F64, incx: BlasInt,
        y: ffi.Span of F64, incy: BlasInt,
    ) -> F64 symbol "cblas_ddot"

def magnitude(value: F64) -> F64:
    unsafe:
        fabs(value as CDouble) as F64

print(magnitude(-2.5))
Header clauseMeans
library "name"The library to link. Required — a block without one is SC0412.
via pkg-config "name"Discover the flags; the plain name stays the fallback, because pkg-config is not on every platform.
kind staticLink the archive rather than the shared object.
when availableLower to a table of function pointers filled in on first use, and give the block an is_available(). One indirect call per foreign call.
ItemMeans
def f(…) -> TA foreign function. No body. symbol "x" after it decouples the Science name from the linker name.
type X is YA spelling for a C typedef.
const K be v as TA #define or an enumerator, transcribed.
static G: TAn exported global. Reading one is a power unsafe grants, because the memory belongs to C.
union U: size n align mAn opaque blob of the right size and alignment, with no way to read the arms.

union and static are live here and held everywhere else. They are keywords of the extern sublanguage and reserved words at the top level, which is the contextual treatment the FFI design settled on and the reason the same two words appear in both tables on this page. The C scalar vocabulary — CChar, CInt, CUInt, CLong, CULong, CLongLong, CULongLong, CFloat, CDouble, CSizeT, CPtrDiff, CVoid — is deliberately not aliased to the Science primitives, because a width the author did not think about is how a numerical program gets silently wrong answers.

Bindings#

One word binds and the same word reassigns: be. A binding is immutable unless it says mutable. The annotation is optional inside a body and mandatory in a signature.

let greeting be "hola"
let answer be 42
let annotated: Int be 42
let widened: I64 be 42i64

let mutable count be 0
count be count + 1
count be count * 2

let view: borrowed String be borrowed greeting
let mutable owner be "mutable text"
let exclusive: mutable borrowed String be mutable borrowed owner

print(count)
print(annotated)

Destructuring exists in exactly one place: a tuple on the left of a be. It is not a general pattern position — there is no let Point(x: a) be p — and the reason it exists at all is that the error model returns a pair, so unpacking one is the shape every fallible call takes.

choice ParseError:
    Empty
    Unsupported(String)

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

let port, err be parse_port("port=8080")
if err?:
    print("bad")
else:
    print(port)
FormMeans
let x be vBind, immutably.
let mutable x be vBind, and allow reassignment.
let x: T be vBind with an annotation. mutable goes before the name, the type after it.
let a, b be f()Unpack a tuple return.
x be vReassign. A statement — it has no value and cannot appear in an expression.
let _x be vA leading underscore says a value is deliberately unused. It is what silences SC0140 on an ignored error.

Scope and shadowing#

A name is visible from where it is bound to the end of the block that binds it, and nowhere else. Items are the exception: a def, a type, a choice, an interface and a const live in a table of their own and are visible throughout the file regardless of order, which is what lets two functions call each other without a forward declaration.

What introduces a scope

ScopeHolds
the moduleEvery item in the file, and whatever a use brings in. Order-free, and consulted only after every block has missed.
a declaration's genericsThe type parameters, in scope for the parameters, the return type, the where clause and the body — and out of scope the moment the declaration closes.
a parameter listThe parameters, including self.
every blockEvery function body, every if branch, every else, every loop, for, match arm and unsafe body — and every inline block. One rule, no exceptions for the short forms.
a patternA for loop's bindings and a match arm's bindings, for the body that follows.
a closureExactly one name: the giving parameter, or the invented each.

A let in a branch is visible to the rest of that branch and to nothing after it. The condition of an if and the iterable of a for are resolved outside the block they head, so a loop cannot see its own bindings in the expression it walks.

error[SC0200]: cannot find `b` in this scope
 --> leaked.science:7:5
  |
7 |     b
  |     ^ not found
1 error

A name may be rebound, and it shadows

A second let on the same name in the same block is legal, silent, and shadows the first. The new binding may have a different type, and its initialiser is resolved before any of its names are defined — so let n be n + 1 reads the old n and then hides it. There is no warning, because the feature exists in order to be used: narrowing a value through a series of steps without inventing n2 and n3 is the whole point.

def shadowed(n: Int) -> Int:
    let n be n + 1
    let n be n * 10
    n

let answer be 4
let answer be shadowed(answer)
print(f"{answer}")
# 50

Shadowing is a property of blocks and of nothing else. A repeated parameter, a repeated generic parameter, and a name bound twice inside one pattern are each SC0201, "the ... is defined more than once", and the first declaration keeps the name. That includes an or-pattern: A(x) | B(x) is documented in the resolver as binding one x and in fact reports SC0201, so an alternative that binds a payload has to be written as two arms today.

error[SC0201]: the binding `x` is defined more than once
 --> alts.science:6:18
  |
6 |         A(x) | B(x): x
  |           - first defined here
  |                  ^ defined again here
1 error

What a top-level statement binds

A file whose top level holds statements is a script: the compiler wraps those statements in the main it writes for you. So a top-level let binds a local, not a module-level name, and the consequence surprises people — it is invisible inside a def in the same file, because a function body is a different scope and the module table holds items only.

error[SC0200]: cannot find `top` in this scope
 --> script.science:3:5
  |
3 |     top
  |     ^^^ not found
1 error

Written out: let top be 7 at the top of a file, then def uses_top() -> Int: with top as its body, does not check. Pass the value as a parameter, or make it a const — a const is an item and is in scope everywhere in the file.

Two consequences of the same rule are their own diagnostics. SC0117: a file may have top-level statements or a hand-written main and not both, because two answers to "what runs first" is not a question the compiler should pick for you. SC0213: top-level statements in a module that is not the entry point are an error, because Science does not run a module's statements when it is imported, so they would never run at all.

error[SC0117]: this file has top-level statements and also declares `main`
 --> both.science:2:5
  |
1 | let a be 1
  | ---------- the top-level statements start here, and are already a `main`
2 | def main():
  |     ^^^^ `main` is declared here
  |
  = note: a file whose top level holds statements is a script: the statements are the program, and the compiler writes the `main` that runs them
  = note: move the statements into `main`, or delete `main` and let the statements be the script
1 error

A local shadows a module too. text.parser folds into a module path only when no enclosing block holds a binding called text; bind that name and the path becomes a field access on your value, which is the reading you asked for. Reserved words cannot be shadowed because they cannot be bound at all: let model be 1 is SC0102, "expected an identifier, found model, which is reserved for a later phase". each is a keyword for that exact reason — no user binding can collide with the one a closure invents.

The type vocabulary#

Eighteen names are primitive, every width is written into the name, and there is no implicit conversion between any two of them — as is always spelled out, including where it loses nothing.

let a: I8 be 1i8
let b: I16 be 2i16
let c: I32 be 3i32
let d: I64 be 4i64
let e: U8 be 5u8
let f: U16 be 6u16
let g: U32 be 7u32
let h: U64 be 8u64
let i: F16 be 1.5
let j: BF16 be 1.5
let k: F32 be 1.5f32
let l: F64 be 2.5
let m: Bool be true
let n: Char be 'x'
let o: String be "owned"
let p: () be ()

let alias_int: Int be 9
let alias_float: Float be 9.5

let maybe: String? be null
let pair: (Int, String) be (1, "one")
let items: Array of Int be (Array of Int).new()
let table: Map of (String, Int) be (Map of (String, Int)).new()

print(alias_int)
print(maybe?)
TypeWidthNotes
I8 I16 I32 I648, 16, 32, 64 bitsSigned. Literal suffixes i8…i64.
U8 U16 U32 U648, 16, 32, 64 bitsUnsigned. Suffixes u8…u64.
F32 F6432, 64 bitsIEEE binary32 and binary64. Suffixes f32, f64.
F16 BF1616 bitsHalf and bfloat16. No literal suffix — a literal reaches them through an annotation.
Bool—true and false, and nothing else is a condition.
CharOne scalar valueSingle quotes. Not a byte.
StringOwned, UTF-8A string literal has this type.
()ZeroUnit — the type and the value share the spelling.
Int Float64 bitsAliases of I64 and F64. Not separate types.
NeverUninhabitedThe type of panic. Coerces to anything, which is what lets one match arm bail out.

F32 and F64 implement Eq and not Ord, because NaN has no total order. That is a deliberate hole in the prelude and not an oversight: a sort over floats has to say what it does with NaN.

Compound types

WrittenMeansNotes
T?A T, or null.Not a wrapper. There is nothing to unwrap and no Option to import.
(A, B)A tuple.Read by destructuring or by a tuple pattern. There is no t.0.
()The empty tuple.The return type of a function with no ->.
Array of TA growable sequence.Built with (Array of T).new().
Map of (K, V)A hash map.Read with .get(k), which returns (borrowed V)?.
Box of TAn owning indirection.Built with Box.new(v), never with named arguments.
borrowed TA shared borrow.Many at once, none of them writing.
mutable borrowed TAn exclusive borrow.One at a time. Both words, in that order.
any IDynamic dispatch over an interface.Only behind an indirection: borrowed any I or Box of any I.
T of AA generic instantiation.One argument bare, several in parentheses: Pair of (A, B).

There is no lifetime syntax, and none is elided. The region of a borrow is inferred and propagated, including through a record field that holds one. That is the language's largest single bet: a borrow checker with nothing to annotate is either sound or it is a bug, and there is no third state where the programmer is asked to help.

Generics#

of is the one word for a type argument, in a declaration and at a use site. A parameter may be a value as well as a type — const WIDTH: Int — which is what the tensor shapes of a later phase are built out of, and which makes a generic parameter an ordinary value inside the body.

interface Summarize:
    def summarize(self) -> String

type Doc:
    title: String

Doc implements Summarize:
    def summarize(self) -> String:
        self.title.truncate(80)

Doc implements Clone:
    def clone(self) -> Doc:
        Doc(title: self.title.truncate(80))

type Pair of (A, B):
    first: A
    second: B

type Window of (T, const WIDTH: Int):
    items: Array of T

Window of (T, const WIDTH: Int) has:
    def empty() -> Window of (T, WIDTH):
        Window(items: (Array of T).new())

    def capacity(self) -> Int:
        WIDTH

def describe of T: Summarize(value: borrowed T) -> String:
    value.summarize()

def best_of of T(left: borrowed T, right: borrowed T) -> String
        where T: Summarize + Clone:
    if left.summarize().length() >= right.summarize().length():
        left.summarize()
    else:
        right.summarize()

def paired of (A, B)(first: A, second: B) -> Pair of (A, B):
    Pair(first: first, second: second)

def describe_any(value: borrowed any Summarize) -> String:
    value.summarize()

def describe_boxed(value: Box of any Summarize) -> String:
    value.summarize()

let doc be Doc(title: "Regions")
print(describe(doc))
print(describe_any(doc))
print(describe_boxed(Box.new(Doc(title: "boxed"))))
print((Window of (Int, 4)).empty().capacity())
print(paired(1, "one").second)
WrittenMeans
def f of T(…)One type parameter.
def f of (A, B)(…)Several.
def f of T: I(…)A bound, written inline.
… where T: I + JSeveral bounds, written after the return type.
type W of (T, const N: Int):A const generic. N is an ordinary value in the body.
(Window of (Int, 4)).empty()An associated function on a generic type. The parentheses are required — the bare form cannot say whether .empty() belongs to 4 or to the whole type, and Science reports the ambiguity (SC0116) rather than making the space load-bearing.

Static dispatch is monomorphised: one specialised copy per instantiation. any I is the dynamic form and it costs an indirection, which is why the language makes you write the word. Both are in the listing above, on the same interface.

The where clause

A bound may be written in two places and they mean the same thing. Inline — def f of T: Summarize(…) — puts the constraint where the parameter is introduced, which reads well when there is one of each. A where clause puts every constraint after the signature instead, which is the only form that stays readable when a parameter carries three bounds or a declaration carries three parameters. The cost of having both is that a reader has to look in two places; the reason for paying it is that a signature is the thing most often read and least often edited, and a line of bounds in the middle of it hides the parameters.

interface Summarize:
    def summarize(self) -> String

interface Clone:
    def clone(self) -> Self

def inline of T: Summarize(value: borrowed T) -> String:
    value.summarize()

def clause of T(value: borrowed T) -> String where T: Summarize:
    value.summarize()

def several of (A, B)(left: borrowed A, right: borrowed B) -> String
        where A: Summarize, B: Summarize + Clone:
    right.clone().summarize()

def both of T: Summarize(value: borrowed T) -> String where T: Clone:
    value.clone().summarize()

Both forms on one declaration is legal and is not a diagnostic. both above declares T: Summarize inline and T: Clone in the clause, and the checker reads the two lists and concatenates them, so T is Summarize + Clone. The syntax tree keeps them apart deliberately — a diagnostic has to be able to point at where the programmer wrote a bound, not at where the compiler filed it.

Where a where may appear. Six declaration forms carry generic parameters, and the clause goes on all six.

DeclarationWritten
defdef f of T(…) -> R where T: I:
typetype Sorted of T where T: Ord:
choicechoice Tagged of T where T: Ord:
interfaceinterface Keyed of T where T: Ord:
implementsSorted of T implements Keyed of T where T: Ord:
hasSorted of T has where T: Ord:
interface Ord:
    def cmp(self, other: borrowed Self) -> Int

type Sorted of T where T: Ord:
    items: Array of T

choice Tagged of T where T: Ord:
    Present(T)
    Absent

interface Keyed of T where T: Ord:
    def key(self) -> Int

Sorted of T implements Keyed of T where T: Ord:
    def key(self) -> Int:
        self.items.length()

Sorted of T has where T: Ord:
    def count(self) -> Int:
        self.items.length()

It goes on the six forms above and nowhere else. A where after a type alias (type E is Array of F32 where …), after a const, or after a use is SC0100, "expected end of line, found where" — those declarations have no generic parameters for a predicate to constrain. It also parses on a tool, because tool and def share the same reader, but a generic tool is SC0191 in the next breath, so the clause has nothing it could bind.

What may be in one. A clause is one or more predicates, and a predicate is a subject, a : and a list of bounds joined by +.

FormMeans
where T: IOne predicate: a subject, a :, a bound.
where T: I + J + KSeveral bounds on one subject. The list ends at the first token that is not +, which is the only thing telling the bounds' : from the block's.
where A: I, B: JSeveral predicates, comma-separated. No trailing comma — there are no brackets here to hold the list open, so where T: I,: is SC0104 and a short cascade after it.
where T: (Int) -> BoolA bound may be a closure type as well as an interface. A bound that opens with ( and has no -> after it is SC0119: the parenthesis has no other reading, so the arrow is not optional there.
where …Written on its own line, it continues the line above — see continuation. It is one of exactly two words-or-symbols that do.

The subject parses as any type and only one shape is honoured. where Array of T: Ord, where borrowed T: Ord and where Int: Ord all parse, and the checker keeps a predicate only when its subject is a bare, argument-free name that resolves to a generic parameter the declaration itself introduced. Everything else is silently discarded, and that is one of the six holes this page records: it is item 3 of Parses, and is not built, with the program that demonstrates it.

Literals#

Four integer bases, a digit separator that works in all of them, ten numeric suffixes, three string forms, a character, two booleans, null and unit. Nothing here is context-dependent: a literal means the same thing wherever it is written.

let decimal be 1_000_000
let hexadecimal be 0xDeadBeef
let binary be 0b1111_0000
let octal be 0o755
let sized be 42i32
let unsigned be 7u8

let pi be 3.14
let exponent be 6.02E23
let single be 2.5f32

let text be "hola"
let escaped be "she said \"hi\"\n"
let codepoint be "\u{1F600}"
let raw be r"C:\runs\2026\peaks.csv"

let letter be 'a'
let newline be '\n'

let yes be true
let absent: String? be null
let nothing be ()

let name be "Kepler"
let count be 3
print(f"{name} has {count} runs, {count * 2} files")
print(raw)
FormExampleNotes
decimal42, 1_000_000_ anywhere between digits, in any base.
hexadecimal0xFF, 0xDeadBeefCase-insensitive digits.
binary0b1111_0000—
octal0o755—
suffix42i32, 7u8, 2.5f32Ten: i8 i16 i32 i64 u8 u16 u32 u64 f32 f64. Combines with any base and with separators.
float3.14, 1e-9, 6.02E23Exponent in either case, sign optional.
string"hola"Type String, owned.
raw stringr"C:\runs\peaks.csv"No escapes at all. Cannot contain a " and is never continued onto the next line. r#"…"# is the known extension and is deferred.
interpolating stringf"{name} has {n}"Each hole holds one expression, rendered by its Display. A literal brace is {{.
character'a', '\n', 'ñ'Exactly one scalar value.
booleantrue, falseKeywords, so neither can be bound.
nullnullA literal, not a prelude value: T? is a type the compiler knows, so what inhabits it is a token the lexer knows.
unit()The value and the type.

The escapes are \n, \t, \r, \\, \", \', \0 and \u{…}. Eight, in a string and in a character alike, and anything else is SC0007.

An f"…" is not one token. It opens, alternates literal runs with {-delimited holes whose interiors are ordinary tokens, and closes — which is what lets f"{m["a"]}" contain a " that does not end the literal, and lets a hole hold a nested f"…". The cost is that a : at the hole's own depth ends the expression, so f"{if flag: "yes" else: "no"}" needs parentheses.

let name be "Kepler"
let count be 3
print(f"{name} has {count} runs, {count * 2} files")
print(r"C:\runs\2026\peaks.csv")
print("a tab\there, a quote \"there\"")
# Kepler has 3 runs, 6 files
# C:\runs\2026\peaks.csv
# a tab	here, a quote "there"

The format specification after a : is specified and unbuilt. Fill, alignment, sign, width, grouping, precision, the type codes and the !i conversion are all designed and none of it exists; a : in a hole is SC0173, which says so. See Parses, and is not built.

What an unsuffixed number is

A numeric literal is inferred, not defaulted — and inferred among the numeric types only. 42 does not arrive as an Int and then get converted; it arrives as an integer literal, joins whatever type the expression around it pins it to, and falls back to I64 — or F64 for a float — only if nothing constrains it at all. That is why let n be 42 followed by takes(n) at an I8 parameter checks, and why 1 + 1u8 makes the 1 a U8 rather than a width error.

The inference is among numbers and not a wildcard, which is the whole difference from a hole. Handing that same n to a String parameter is SC0525, and the diagnostic names the pending type by what it is:

error[SC0525]: expected `String`, found `an integer literal`
 --> pending.science:5:13
  |
5 | print(wants(n))
  |             ^ this is `an integer literal`
1 error

An integer literal is accepted at a floating position — let x: F64 be 1 — because that is what a scientific program writes and 1 is exact there. A float literal is not accepted at an integer one: let n: I32 be 1.5 is a value the target cannot hold, and admitting it is how a language becomes wrong on somebody's index arithmetic.

The edges of a numeric literal

WrittenRead asBecause
1.5a floatA . makes a float only when a digit follows it.
1.round()an integer, then a method callSame rule, read the other way: no digit after the dot, so the literal ends at the 1. This is what keeps method calls on integer literals possible.
1.an integer, then a dotAnd then SC0102, because a . wants a name after it.
6.02e23a floatAn exponent, in either case, with an optional sign.
1ean integer with the suffix eAn exponent needs digits. Without them the e is word-shaped, so it is read as a suffix — and then SC0009.
1f32a floatA float suffix makes a float with neither dot nor exponent. The reverse does not hold: 1.5i32 is SC0009.
42fooone broken literalEverything word-shaped after the digits is consumed as the suffix, so this is SC0009 and not an integer standing beside an identifier.
0x1.8not a hexadecimal floatA prefixed literal is never a float. 0x1, then a dot, then 8 — and SC0102.
1_0i64   0b1111_000010 and 240_ separates digits in every base, anywhere among them, and combines with a suffix.

The ten suffixes are i8 i16 i32 i64 u8 u16 u32 u64 f32 f64, and they are the spelling of the literal: the type names are I8…U64, F32, F64. A literal's value is accumulated into a 128-bit accumulator and SC0005 fires on overflow, but the number the diagnostic quotes is U64's, because that is the widest integer type the program actually has.

The array literal

[a, b, c] is an Array of T where T is the unification of its elements. A [ in prefix position is what tells the literal from an index, which is a postfix [, and a trailing comma is allowed like everywhere else. An empty literal takes its element type from the position it sits in and has no other source for one.

let counts be [1, 2, 3]
let empty: Array of Int be []
let widths: Array of U8 be [1u8, 2u8,]
print(f"{counts.length()} {empty.length()} {widths.length()}")
WrittenMeans
[1, 2, 3]An Array of I64 — the elements are unconstrained integer literals, so the fallback applies.
[1, "two"]SC0281, once per element that disagrees, with the element that set the type underlined as well. There is no implicit numeric conversion, so [1, 3.5] is the same error.
[]SC0282 unless the position says what it holds. Annotate the binding.
[x for x in xs]SC0153. Science has no comprehensions; the chain does the same thing, lazily and in reading order.
error[SC0281]: the elements of this array literal do not have one type
 --> mixed.science:1:18
  |
1 | let mixed be [1, "two", 3.5, true]
  |               - this is an integer literal
  |                  ^^^^^ this is `String`
  |
  = note: an array literal is an `Array of T` where `T` is the unification of its elements (§3.1), and there is no implicit numeric conversion (§5.1): write the suffix, the `as`, or the literal you meant
1 error

Indexing is the other half of the bracket and has four diagnostics of its own: SC0151 for a[], SC0152 for a[-1] — a negative index does not count from the end, and the note says why Python's reading is the trap it is — SC0154 for a range that starts past where it ends, and SC0538 for a range index at all, since there is no Slice for it to produce.

Operators and precedence#

Eleven rungs. The table below is read off binary_op and the expression descent in crates/science-parser/src/parser.rs, not off a note — the parser has a unit test that asserts the ten infix rungs against the same numbers, so a row that lost an operator to a typo would fail a test rather than show up as a wrong parse somewhere far away.

BindsOperatorsAssociatesWhere it lives
tightestf(x)   a[i]   a.b   a.b(x)   e?left to rightthe descent — postfix
↑-   not   borrowed   mutable borrowedprefixthe descent — unary
↑asleftthe descent — cast
10**rightinfix table
9*   /   %   @leftinfix table
8+   -leftinfix table
7<<   >>leftinfix table
6&leftinfix table
5^leftinfix table
4|leftinfix table
3is   is not   <   >   <=   >=leftinfix table
2andleftinfix table
1orleftinfix table
loosest..   ..=neither — both ends required, no chainingabove the climb
outsidex giving etakes the whole right-hand sideread before the climb starts
not an operatorbe—a statement; the parser asserts it has no rung

Three rows are worth a sentence each. ** is the only right-associative operator, so 2 ** 3 ** 2 is 2 ** (3 ** 2); every other row absorbs to the left. .. is looser than everything, which is what makes 0..n - 1 count to n - 1 rather than subtracting from a range. giving is read above the whole table, because there is no operator its right-hand side could be an operand of: doc giving doc.title.length() gives the closure everything after the word.

let a be 10
let b be 3

let power be 2 ** 3 ** 2
let mixed be 1 + 2 * 3
let bits be 1 + 1 << 2 | 0b1 ^ 0b10 & 0b11
let ordered be a + b < a * b
let identical be a + b is a * b
let logical be a < b or a > b and true
let negated be not (a < b)
let widened be a as F64 * 2.0

print(power)
print(mixed)
print(bits)
print(ordered)
print(identical)
print(logical)
print(negated)
print(widened)

The parts of that listing the backend can emit — everything but **, the shift and the cast — run and print this:

let a be 10
let b be 3
print(f"{a + b} {a - b} {a * b}")
print(f"{1 + 2 * 3} {(1 + 2) * 3}")
print(f"{a < b or a > b and true} {not (a < b)}")
# 13 7 30
# 7 9
# true true

Identity in words, order in symbols

is is equality and is not is inequality, and they are the only spelling identity has. == and != are not Science and the lexer reports them as SC0016 and SC0017 with the word as a machine-applicable fix. Ordering has no word form and needs none: >= is four hundred years old.

error[SC0016]: equality is written `is`
 --> comparison.science:2:9
  |
2 | print(a == 1)
  |         ^^ `==` 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

@ is matrix multiply and sits with * / %. There is no tensor in the library today, so it reaches a user type through the MatMul interface exactly as + reaches one through Add. The operator and its precedence are settled now so that the phase that brings tensors does not get to move them.

Calls, chains, closures#

A call is f(x); a method call is a.m(x); a named-argument call on a path is a record construction and nothing else. Chains are the shape most Science code takes, and there are two spellings for the function a link needs.

type Doc:
    title: String
    body: String

Doc has:
    def is_empty(self) -> Bool:
        self.body.is_empty()

def headlines(docs: borrowed Array of Doc) -> Array of String:
    docs
        .iterate()
        .discard(each.is_empty())
        .map(each.title)
        .take(5)
        .collect()

def by_length(docs: borrowed Array of Doc) -> Array of String:
    docs
        .iterate()
        .map(doc giving doc.title)
        .sort(by: line giving line.length())
        .collect()

let docs be (Array of Doc).new()
print(headlines(docs).length())
print(by_length(docs).length())
FormMeans
each.fieldA closure over the enclosing call's element, with no parameter declared. Cannot nest — a nested each is SC0115 rather than a rule nobody would remember.
x giving eA closure that names its parameter. Takes the whole expression to its right.
f(label: v)A labelled argument. On a path this is a record construction instead; resolution tells them apart, because only it knows whether the path is a module.
a.m(x)A method call. A borrowed parameter is borrowed at the call site with nothing written for it.
a[i]An index — [ in postfix position, binding with call and field access.
[a, b]An array literal — [ in prefix position. The rule is parser position and nothing else. See below: this parses and is not type-checked.
unsafe:A block expression. Its value is its body's value.

A chain breaks across lines inside an unclosed bracket, where the line structure is suspended, and that is the only line-continuation rule in the language — there is no backslash.

Branches and loops#

if is an expression, so it is also the conditional. Two loops: for x in xs walks anything implementing Iterate, and loop takes no condition at all. There is no while, and the reason is that a loop's condition is usually the first statement of its body once it is written honestly.

def longest(a: borrowed String, b: borrowed String) -> borrowed String:
    if a.length() > b.length(): a else: b

def bucket(n: Int) -> String:
    if n < 10: "small" else: if n < 100: "medium" else: "large"

def sign(n: Int) -> Int:
    if n < 0: return -1
    if n > 0: return 1
    0

def dangling(a: Bool, b: Bool):
    if a: if b: print("both") else: print("only a")

def not_dangling(a: Bool, b: Bool):
    if a:
        if b: print("both")
    else:
        print("neither")

let flag be true
let chosen be if flag: 1 else: 0

print(longest("abc", "de"))
print(bucket(42))
print(sign(-3))
print(chosen)
dangling(true, false)
not_dangling(false, false)

An inline block ends where its expression ends, not at the end of the line. In if c: a else: b the then-branch ends mid-line, at else, because else cannot continue an expression. "Fits on one line" is a consequence of that rule and not the rule. 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 (SC0109). A dangling else binds to the innermost if, and that is a language rule rather than an artefact of recursive descent.

def sign(n: Int) -> Int:
    if n < 0: return -1
    if n > 0: return 1
    0

print(f"{sign(-3)} {sign(0)} {sign(9)}")
# -1 0 1
def ruler(width: Int) -> Int:
    let mutable marks be 0
    for i in 0..width:
        if i % 5 is 0:
            marks be marks + 1
    marks

def triangular(n: Int) -> Int:
    let mutable sum be 0
    for i in 0..=n:
        sum be sum + i
    sum

def countdown(start: Int):
    let mutable n be start
    loop:
        if n <= 0:
            break
        print(n)
        n be n - 1

def first_multiple(limit: Int, factor: Int) -> Int?:
    for i in 1..=limit:
        if i % factor is not 0:
            continue
        return i
    null

def count_spaces(text: borrowed String) -> Int:
    let mutable spaces be 0
    for c in text.chars():
        if c is ' ':
            spaces be spaces + 1
    spaces

print(ruler(20))
print(triangular(4))
countdown(3)
let found be first_multiple(20, 7)
if found?:
    print(found)
print(count_spaces("one two three"))
FormMeans
for x in xs:Walks anything implementing Iterate.
for i in 0..n:Half-open — stops one short of n. This is the counting loop.
for i in 0..=n:Inclusive — visits n itself.
loop:Unbounded. Ended by a break or a return in its body.
breakEnds the innermost loop only.
continueNext iteration of the innermost loop.
for _ in 0..n:A loop whose element is deliberately unused.

A range's ends are written with .. and never with :, which already opens blocks and separates match arms, fields, named arguments and bounds. Both ends are required — see below for what that costs.

else if, and the two block shapes

Every block in the language is one of two shapes and they mix freely: a : then a newline and an indented body, or a : then a single statement on the same line. else if is not a third thing and not a keyword — it is an else whose body is an if, which is why it takes no : of its own and why there is no elif to remember.

def bucket(n: Int) -> String:
    if n < 10:
        "small"
    else if n < 100:
        "medium"
    else:
        "large"

def bucket_inline(n: Int) -> String:
    if n < 10: "small" else: if n < 100: "medium" else: "large"

def mixed(n: Int) -> Int:
    if n < 0:
        let magnitude be 0 - n
        magnitude
    else if n is 0:
        0
    else:
        n

def short(n: Int) -> Int:
    if n > 0:
        1
    else: 0

print(f"{bucket(42)} {bucket_inline(4)} {mixed(-3)} {short(9)}")
ShapeHolds
: ↵ indentedOne or more statements. The last expression is the block's value.
: one statementAn expression, an assignment, or return / break / continue. Not a let, which would bind a name nothing could then use — SC0109.
else ifAn else whose body is an if. No : after else, and the chain nests rather than flattening.
else:A block like any other. A dangling else binds to the innermost if, by rule rather than by accident of the descent.

A : with neither an indented block nor something that can start a statement after it is SC0103, "expected an indented block or a single expression after :". match is the one construct with no inline form: its arms are always :, newline, indented — match c: A: 1 on one line is SC0100. Individual arms take either shape.

What the loop words do and do not take

WrittenRule
for p in xs:The subject is a pattern, not just a name, so for (a, b) in pairs:, for _ in 0..n: and for mutable x in xs: all parse. The parentheses on a tuple are required — for a, b in xs is SC0100, "expected in, found ,".
for each x in xs:SC0138. each names the subject of a call, as in docs.map(each.title), and nothing else.
while c:SC0142, with the whole head underlined and loop offered as the fix — the diagnostic is reproduced under No while. The condition is parsed and then discarded and the body is kept, so the rest of the function still checks against something.
breakEnds the innermost loop. No label. A value after it parses, and there is no loop-as-expression for that value to become, so it is read as a separate statement rather than as the loop's result.
continueNext iteration of the innermost loop. No value, no label.
returnWith a value or without. It is the only way out of a loop that also leaves the function, and it works from inside any depth of block.

All three are statements and not expressions, so let x be break does not parse. The one place they behave like expressions is an inline block, where each is allowed as the single statement — a concession made because each has type Never in every respect except which list the syntax tree files it under.

match and patterns#

A match answers for every value or the program has no answer for the rest, so exhaustiveness is checked and a missing variant is named in the diagnostic rather than left to a wildcard. Every pattern form the language has is in the listing below.

choice Format:
    Plain
    Markdown
    Json

choice Token:
    Ident(String)
    Number(Int)
    Symbol(Char)
    Eof

type Point:
    x: Int
    y: Int

def copy_of(text: borrowed String) -> String:
    let mutable out be String.new()
    out.push_str(text)
    out

def name_of(format: borrowed Format) -> String:
    match format:
        Plain: "plain"
        Markdown: "markdown"
        Json: "json"

def qualified(format: borrowed Format) -> String:
    match format:
        Format.Plain: "plain"
        Format.Markdown: "markdown"
        Format.Json: "json"

def digit_name(digit: Int) -> String:
    match digit:
        0: "zero"
        1: "one"
        _: "many"

def size_class(n: Int) -> String:
    match n:
        0 | 1 | 2: "small"
        3 | 4 | 5 | 6: "medium"
        _: "large"

def quadrant(point: (Int, Int)) -> String:
    match point:
        (0, 0): "origin"
        (x, 0): "on the X axis"
        (0, y): "on the Y axis"
        (_, _): "somewhere else"

def classify(point: borrowed Point) -> String:
    match point:
        Point(x: 0, y: 0): "origin"
        Point(x: 0, y: y): "on the Y axis"
        Point(x: x, y: _): "general"

def payload(token: borrowed Token) -> String:
    match token:
        Ident(name): copy_of(name)
        Number(value): digit_name(value)
        Symbol(','): "comma"
        Symbol(c): "symbol"
        Eof: panic("expected a token, found the end of input")

def passthrough(n: Int) -> Int:
    match n:
        0: 1
        other: other

let label be match Markdown:
    Plain: "plain"
    Markdown: "markdown"
    Json: "json"

print(label)
print(name_of(Json))
print(qualified(Format.Json))
print(size_class(4))
print(quadrant((0, 3)))
print(classify(Point(x: 1, y: 0)))
print(payload(Ident("width")))
print(passthrough(9))
PatternMatches
PlainA variant with no payload. Unqualified.
Format.PlainThe same variant, qualified by its type.
Ident(name)A variant, binding its payload.
Symbol(',')A variant whose payload is itself matched.
0   'a'   "hi"   trueA literal, of any literal type.
a | b | cAlternatives. Composes with the forms above.
(x, 0)A tuple, positionally.
Point(x: 0, y: y)A record, by field name — mirroring construction exactly.
Point(x: x, y: _)A record ignoring a field.
otherA binding that catches everything and names it.
_The wildcard: catches everything and names nothing.

An arm's body is inline after the :, or an indented block, and the two mix freely inside one match. There is no guard clause — no if inside a pattern — because a condition on a bound payload is an if in the arm body, one line down, and a guard is the feature that makes exhaustiveness checking a guess.

error[SC0250]: `borrowed Format` has a value no arm of this `match` covers: `Json`
 --> exhaustive.science:7:11
  |
7 |     match format:
  |           ^^^^^^ this is `borrowed Format`
  |
  = note: a `match` answers for every value or the program has no answer for the rest (§4.5); write an arm for `Json` — or a `_` arm, which covers everything the arms above leave
1 error

panic has type Never, which coerces to any type. That is what lets one arm bail out while the others produce a String, without the match losing its type.

choice Format:
    Plain
    Markdown
    Json

def name_of(format: Format) -> String:
    match format:
        Plain: "plain"
        Markdown: "markdown"
        Json: "json"

print(name_of(Plain))
print(name_of(Json))
# plain
# json

Two more pattern forms, and every form there is not

null is a literal, so it is a literal pattern like any other, and a binding may be mutable — which is the only way to change a value a match just bound without copying it into a fresh let first.

def present(x: Int?) -> Int:
    match x:
        null: 0
        _: 1

def bump(n: Int) -> Int:
    match n:
        0: 0
        mutable other:
            other be other + 1
            other

print(f"{present(null)} {present(3)} {bump(7)}")

Matching null does not narrow the other arm: in match x: with a null arm and a binding arm, the binding still has type Int? and using it as an Int is SC0525. Narrowing is what if x?: is for, and match over a nullable is for the shape rather than for the unwrap.

A pattern is a closed grammar and everything below is refused. Each is a feature some other language has, and the entry says what to write instead.

Not a patternWhat happensWrite
1..5:SC0100, "expected :, found ..". There is no range pattern.An if in the arm body, or alternatives.
x if x > 0:SC0100, "expected :, found if". There are no guards, and that is the decision, not an omission: a guard is the feature that turns exhaustiveness checking into a guess.An if in the arm body, one line down.
whole @ A(v):SC0100, "expected :, found @". @ is matrix multiply and binds nothing.A second arm, or bind the whole value and destructure inside.
-1:SC0106, "expected a pattern, found -". A negative literal is a unary operator applied to a literal, and a pattern holds no operators.A binding arm, and compare in the body.
[a, b]:SC0106, "expected a pattern, found [". There are no array or slice patterns, for the same reason there is no Slice.Index, or iterate.
Doc(title):SC0206 — "Doc is a record type, so its pattern names its fields". There is no field shorthand: a record pattern mirrors construction exactly, and construction names every field.Doc(title: title)
A(x) | B(x):SC0201. An alternative that binds is one binding too many for the shared scope, so alternatives work for unit variants and literals and not for payloads. See Scope and shadowing.Two arms.
(a, ..):SC0106. No rest pattern, in a tuple or a record.(a, _)

Absence and failure#

Two shapes, and they are deliberately the same shape. Absence is T?: a value of that type is a T, or it is null. Failure is a second return value: -> (T, E?), so a function that can fail hands back what it produced and what went wrong. ? is the postfix presence test — err? is a Bool — and after if err?: return … the compiler knows the value is good.

type Config:
    host: String
    port: U16

Config has:
    def empty() -> Config:
        Config(host: String.new(), port: 0u16)

choice ConfigError:
    NotFound(String)
    Malformed(String, Int)

ConfigError implements Error:
    def message(self) -> String:
        match self:
            NotFound(key): copy_of(key)
            Malformed(detail, line): copy_of(detail)

def copy_of(text: borrowed String) -> String:
    let mutable out be String.new()
    out.push_str(text)
    out

def heading(text: borrowed String) -> String?:
    if text.starts_with("# "):
        text.truncate(60)
    else:
        null

def parse_port(text: borrowed String) -> (U16, ConfigError?):
    if text.is_empty():
        return (0u16, ConfigError.Malformed("empty input", 1))
    if not text.starts_with("port="):
        return (0u16, ConfigError.NotFound("port"))
    (8080u16, null)

def read_config(path: borrowed String) -> (Config, Error?):
    let port, err be parse_port(path)
    if err?:
        return (Config.empty(), err)
    (Config(host: copy_of(path), port: port), null)

def save(path: borrowed String) -> Error?:
    let config, err be read_config(path)
    if err?:
        return err
    null

let title be heading("# Regions")
if title?:
    print(title)
else:
    print("no heading")

let config, err be read_config("port=8080")
if err?:
    print(err.message())
else:
    print(config.port)
FormMeans
T?A T or null. A field, a parameter and a return type like any other.
e?Presence. A Bool, and a total operator — it can only produce a value and never returns from the function around it.
-> (T, E?)Fallible, with a concrete error. match over it stays exhaustive and it does not box.
-> (T, Error?)Fallible, with the interface error. Shorthand for (T, (any Error)?). Composes across a library boundary, and boxes.
-> Error?Fallible with nothing to return. The pair would have () in its first slot.
if err?: return …The check. The narrowing that follows is what keeps the model from being cast-ridden.

? binds on the tightest rung with call, index and field access and is postfix, so registry.points.get(0)? asks about the result of the call, not about registry. Covering less takes parentheses. Because it produces a Bool it composes with and, or and not like any other boolean.

SC0140 is what recovers most of what a Result guaranteed: an error bound and never tested is a compile error.

error[SC0140]: `err` is never checked for an error
 --> unchecked.science:5:15
  |
5 |     let text, err be load("notes.txt")
  |               ^^^ 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

Four things excuse an untested error and nothing else does: naming it _err, returning it, passing it on, and a body that cannot return at all. The check is on the binding, not on the call, which is why it survives the value being used — print(value) does not count as having looked at err.

The hole in that check, and why this entry is the softest on the page

The condition the design states is "E? where E implements Error", and read literally that is silent on the program the language itself recommends: a function naming a concrete choice as its error so the caller can match on it exhaustively, with no implements Error block. The corpus writes exactly that. So the one construct that recovers what a Result guaranteed had a shape it did not cover, and the shape was the recommended one.

That coverage is being changed as this page is written, so it is the one claim here not worth pinning to a number: the rule is moving from the error type to the error position — the last binding of a several-binding let, of nullable type, whose initialiser is a call. Check it against your own compiler before relying on it either way. The advice that survives the change is unchanged: if you take the concrete error form for the match it buys, test the error by hand and do not wait to be told.

There is no Option, no Result, no Some, no None, no Ok, no Err, no try, no unwrap and no is_some — because there is no wrapper type left for those methods to hang off. ? is the only operation a nullable has.

Every reserved word#

The lexer's from_word in crates/science-lexer/src/token.rs maps 68 words to something other than an identifier. Forty are live — they mean something today. Twenty-eight are held — reserved for a later phase, an error to use, and never an identifier. Both tables below are that function, read out.

Live — 40 words

WordMeans
defDeclares every function, method, associated function and interface member.
toolA function a model may call, with five extra checkable obligations.
typeA record (type X:) or an alias (type X is Y).
choiceA sum type.
interfaceRequired methods, defaults and associated types.
implementsT implements I: — supplies an interface.
hasT has: — methods from no interface.
ofA generic argument, in a declaration and at a use site.
whereA bounds clause after a return type.
constA compile-time constant, and a const generic parameter.
publicExports an item from its module.
useImports a module or selected names from one.
externOpens a foreign declaration block. Always preceded by unsafe.
unsafeOn an extern block, and as a block expression.
letIntroduces a binding.
beBinds, and reassigns. One word for both.
mutableOn a binding, on self, and before borrowed.
borrowedA shared borrow, in a type and as a unary operator.
anyDynamic dispatch: any I.
asThe cast. Always written; there is no implicit conversion.
ifA branch, and an expression.
elseThe other branch. Binds to the innermost if.
matchAn exhaustive dispatch over patterns.
forfor x in xs:.
inThe second half of for.
loopThe unbounded loop. Takes no condition.
breakEnds the innermost loop.
continueNext iteration.
returnReturns from the enclosing function.
eachNames a chain link's element without declaring it. Reserved for that alone.
givingA named closure: x giving e.
andLogical conjunction, rung 2.
orLogical disjunction, rung 1.
notLogical negation, unary.
isEquality; is not is inequality. Followed by at most one more word.
selfThe receiver of a method.
SelfThe implementing type, inside an interface or implementation.
trueA Bool literal.
falseA Bool literal.
nullThe other value of a T?. A literal, not a prelude value.

_ is a token of its own and not a word in that list: it is the wildcard pattern and the "deliberately unused" prefix. library, via, pkg-config, kind, when, available, symbol, size and align are not keywords: they are recognised positionally inside an extern header or item and remain ordinary identifiers everywhere else.

Held — 28 words

Each of these lexes to a reserved token. Using one as a name is an error today and will not be a breaking change when the phase that wants it lands.

WordHeld for
agentF4 — agent as a declaration construct.
promptF4 — a prompt as a declaration construct.
modelF4, hypothetically. The audit asks to free it; it is the most common variable name in machine-learning code.
spawnF3 — actors.
sendF3 — actors.
receiveF3 — actors.
superviseF3 — supervision trees.
asyncConcurrency. The concurrency note declines the model and keeps the word.
awaitThe same decision, the same note.
durableF5 — durability.
checkpointF5. Collides with a training checkpoint in binding position.
resumeF5 — resuming a lowered state machine.
tensorF1, hypothetically. The audit asks to free it; the type Tensor is capitalised and was never at risk.
shapeF1, hypothetically. The audit asks to free it, and the broadcasting note has since confirmed F1 wants no shape declaration at all.
parallelF2 — a parallel chain link, where the word on the screen is the point.
kernelF2 — a GPU kernel. The audit asks to free it: it is also a KDE kernel, an NMR window and a nullspace.
equationAn equation as a construct: dimensionally checked at compile time and rendered to the paper from the same definition that ran. formula was rejected because chem already spells a chemical formula that way.
pureA purity marker on a declaration, with its own diagnostic in the effects design.
assertA verification construct, not a library function — a place where the compiler can sometimes discharge the condition statically. Kept deliberately, and unbuilt.
withA block binding const dimension parameters, in the broadcasting design.
onA parameter label — train(net, on: data).
moveAn explicit move, at a C boundary.
yieldA generator form no phase asks for. The audit asks to free it: it is the result in chemistry, agronomy and finance.
modA module declaration form that does not exist — mod.science is a filename, and filenames are not identifiers. The audit asks to free it for arithmetic modulo.
importNothing: Science spells it use. The audit asks to free it.
unionLive inside an extern block; held at the top level. The audit asks to free it there, for set algebra.
staticLive inside an extern block; held at the top level, where there are no mutable globals to declare.
macroA macro form, checked against the reserved list by the self-hosting design.

Eight names are deliberately not reserved, because each is a common variable name in the code of the people this language is for:

  • grad
  • dim
  • dims
  • axis
  • device
  • dtype
  • unit
  • alias

function is free too, and that is a decision rather than an omission: it was the declaration keyword until syntax revision 3 and is now an ordinary word a program may bind. The stale declaration is recognised positionally — the word function followed by a name, one token of lookahead — which is SC0156 with a machine-applicable fix. A reservation would have bought the same diagnostic and cost the identifier, which the FFI design already binds as a C struct field.

What using a held word looks like

error[SC0102]: expected an identifier, found `model`, which is reserved for a later phase
 --> naming.science:1:5
  |
1 | let model be 3
  |     ^^^^^ expected an identifier, found `model`, which is reserved for a later phase

error[SC0105]: expected an expression, found `model`, which is reserved for a later phase
 --> naming.science:2:7
  |
2 | print(model)
  |       ^^^^^ expected an expression, found `model`, which is reserved for a later phase
2 errors

Three places the design notes and the lexer disagree

1. The audit asks to free eight words, and all eight are still held. shape, model, tensor, mod, yield, kernel, union and import are each marked Free in reserved-words.md §5, and from_word maps every one of them to Reserved(…) today. The recommendation was strengthened rather than withdrawn — §6 records that the F1 design has since confirmed it wants no shape declaration, so the reservation protects a construct nothing asked for — and it has not been acted on.

2. The dot rule is not implemented. §0.1 of that note asks for one function: after a ., accept any word as a member name, because no keyword can begin anything in that position. It calls the change "required regardless of every other decision here". The parser still calls expect_ident after a Dot, so x.shape, a.union(b), values.any(), re.match(s), n.mod(m) and frame.import() all fail — and g.shape produces three errors rather than one, because the parser cannot resynchronise on a word it refused.

3. There is no SC0002. §5.1 of the note describes SC0002 as "a word reserved for a later phase used as a name" and asks for its message to gain a fix. No such code exists in any crate; a held word surfaces as whatever the parser wanted there instead — SC0102 in binding position, SC0105 in expression position — which is true and useless, exactly as the note predicted for the member case. The core spec's §13 also still lists tool, extern and unsafe as "reserved, not yet used", and the lexer makes all three live keywords.

Parses, and is not built#

Six constructs get through the parser and stop somewhere after it. A page that listed them beside the working forms would be worse than one that omitted them, so they are here, each with the diagnostic it produces today — or, in two cases, with the fact that it produces none, which is the more expensive kind.

1. a[i] be v reaches an interface you cannot supply

Indexing is two interfaces: Index reads and yields borrowed Output; IndexMutably writes and yields mutable borrowed Output. a[i] be v parses, dispatches to IndexMutably, and works for the built-in Array. Writing your own is where it stops: the obvious implementation reads through the backing Array, an index read yields borrowed T, and nothing in the language turns that into a mutable borrowed T.

type Grid:
    cells: Array of Int

Grid implements IndexMutably of Int:
    type Output is Int

    def index_mutably(mutable self, at: Int) -> mutable borrowed Self.Output:
        self.cells[at]

let mutable grid be Grid(cells: (Array of Int).new())
grid[0] be 9
error[SC0525]: expected `mutable borrowed Int`, found `borrowed Int`
 --> writing.science:8:9
  |
8 |         self.cells[at]
  |         ^^^^^^^^^^^^^^ this is `borrowed Int`
1 error

Writing mutable borrowed self.cells[at] does not rescue it — that is mutable borrowed borrowed Int. What does check is an IndexMutably whose output is reached through a field rather than through an index, so the interface is implementable and the container it is wanted for is not.

2. Slicing does not exist, in two different ways

a[1..5], a[2..], a[..5] and a[..] are all specified as index-bracket forms, and a range index is specified to produce a Slice of T — a borrow and a length, which does not copy. None of the four works, and the two halves fail at different stages.

The open-ended forms do not parse: parse_range requires both ends, because an open end would have to be told from a .. that ends a line.

error[SC0105]: expected an expression, found `]`
 --> slice.science:2:19
  |
2 | let tail be xs[2..]
  |                   ^ expected an expression, found `]`
1 error

The closed form parses and is refused by the type checker, which is the better of the two failures and was not always the case: this used to check clean and hand back borrowed Int — one element rather than a slice — because the untyped range and the Int the Index implementation wanted agreed by cancelling instead of by matching. It now says what it means.

error[SC0538]: a range index would produce a slice, and there is no `Slice` type
 --> slice.science:2:5
  |
2 |     xs[1..3]
  |     ^^^^^^^^ slicing a `borrowed Array of Int` is not implemented
  |
  = note: `a[1..5]` is specified as a `Slice of T` — a borrow and a length, which does not copy (§2.3) — and this compiler declares no such type. Index one element at a time, or iterate: `xs.iterate().skip(1).take(2)`
1 error

There is no Slice in the prelude to name either: Slice of Int in a signature is SC0200, "cannot find Slice". Index one element at a time, or iterate.

3. A where predicate on anything but a bare parameter is discarded

A clause's subject is read by the full type grammar, so where Array of T: Ord, where borrowed T: Ord and where Int: Ord all parse. The checker then keeps a predicate only when its subject is a bare, argument-free name resolving to a generic parameter the declaration itself introduced, and drops every other predicate — no diagnostic, no note, no warning. The listing below checks clean, and D implements Ord nowhere.

interface Ord:
    def cmp(self, other: borrowed Self) -> Int

type D:
    v: Int

def held of T(x: borrowed T) -> Int where Array of T: Ord, borrowed T: Ord, Int: Ord:
    0

def calls(d: borrowed D) -> Int:
    held(d)

print(f"{calls(borrowed D(v: 1))}")

The bare form is checked properly, and the difference is the measurement: change that clause to where T: Ord and the same call is SC0534, "D does not implement Ord", with the declaration site pointed at as well. So the failure is not that bounds go unchecked — it is that a bound the checker does not recognise looks exactly like one it satisfied. Write the subject as a bare parameter name, and put anything else in the body, where it will be checked.

What used to be here, and is not any more

This slot held the array literal, which parsed and was not type checked at all: let counts: Array of String be [1, 2, 3] checked clean, and let counts be [1, 2, 3] left its binding at the error type with no diagnostic, so every check downstream of it was vacuous. SC0281 — the code the design allocated for exactly this — was defined in no crate. It is defined now, and a second code, SC0282, covers the empty literal that has nothing to take an element type from. Both forms are in Literals as working syntax. This paragraph is here rather than deleted because a reference that quietly drops a hole teaches the reader nothing about how far to trust the rest of the list.

4. The format mini-language is specified and unbuilt

Fill, alignment, sign, width, grouping, precision, the type codes and the !i conversion are all designed, and checkable at compile time against the argument's static type. None of it exists. The compiler refuses a specification rather than parsing and ignoring one, which would look supported.

error[SC0173]: a format specification, which this compiler does not implement
 --> format.science:2:12
  |
2 | print(f"{pi:.2f}")
  |            ^^^^ no part of the format mini-language is built
  |
  = note: `f"{value}"` is the whole of what this compiler reads: an expression, rendered by its `Display`. The fill, alignment, sign, width, grouping, precision and type codes, and the `!i` conversion, are specified and unbuilt
1 error

5. SC0286 — bounds checking — does not exist

The design gives every a[i] an index obligation — 0 <= i and i < extent — discharged statically where it can be, and warned about under SC0286 where it cannot. No pass discharges one and no crate defines the code; the whole SC0280–SC0289 band is unallocated. An index is unchecked at compile time and the runtime fallback is a panic that has never run.

6. Interpolating a narrowed T? emits an invalid module

Four lines, checking clean, that the backend miscompiles. The nullable's two-word representation is handed to a runtime entry point that takes one word, and LLVM's own verifier catches it before the linker is reached.

let mutable maybe: Int? be null
maybe be 7
if maybe?:
    print(f"{maybe}")
error[SC0402]: the linker failed
  = note: the command was: (the backend, before the linker)
  = note: what follows is the linker's own output, verbatim:
  = note: the module failed verification in `<module>`: Call parameter type does not match function signature!
  %v10 = load [2 x i64], ptr %l1, align 8
 i64  call void @science_string_push_i64(ptr %v9, [2 x i64] %v10)

That is the good failure: the backend verifies what it emits and stops rather than linking a binary that would read the wrong word. Narrowing works everywhere check can see it; it is the lowering that has not caught up.

What there is instead#

Twelve things a reader arriving from Python, Rust or Go will look for and not find. Each one is a decision with a replacement, and for eight of them the compiler says so itself rather than failing on the grammar — a migration diagnostic that names the word and, where there is one spelling to swap in, carries a machine-applicable fix.

Not in the languageWrite insteadDiagnostic
while c:loop:   if not c: breakSC0142
class C:type C:  …  C has:a grammar error
a if c else bif c: a else: ba grammar error
try / catchlet v, err be f()   if err?:a grammar error
try f()the same three linesSC0155
Result of (T, E)-> (T, E?)SC0200
Option of T, Some, NoneT? and nullSC0200
<'a> lifetimesnothing — regions are inferreda grammar error
[x * 2 for x in xs]xs.iterate().map(each * 2).collect()SC0153
t.0let a, b be tSC0102
f"{x:.2f}"nothing yet — see aboveSC0173
a[-1]a.last()SC0152
==, !=is, is notSC0016, SC0017
function f():def f():SC0156
pubpublica grammar error

No while

A while loop's condition is nearly always a restatement of the break that ends it, and a counted loop is a range. What is left is the loop whose end is a condition on the first line of its body, so that is what loop is — and the diagnostic teaches both replacements rather than one.

error[SC0142]: the unbounded loop is written `loop`
 --> loop.science:2:1
  |
2 | while n > 0:
  | ^^^^^^^^^^^ `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

No comprehensions

One form per operation. A comprehension and a chain do the same thing, and keeping both means every reader learns both and every author picks. The chain is the one kept, because it reads in the order it runs and it composes past three links where a comprehension does not.

error[SC0153]: Science has no comprehensions; write the chain
 --> chain.science:2:16
  |
2 | let doubled be [x * 2 for x in xs]
  |                ^^^^^^^^^^^^^^^^^^^ this bracket holds a comprehension, not a list
  |
  = note: the chain does the same thing, lazily and in reading order: `source.iterate().keep(<condition>).map(<expression>).collect()`
  = note: `keep` comes before `map`, and `each` names the element, so the chain declares no loop variable
1 error

A comprehension is caught on the syntax tree, before types, and that is deliberate: a comprehension type-checks as nothing at all, so a phase that let it through would report the absence of a chain three times and the comprehension never.

No try, and no Result for it to unwrap

try f() was one line and the check is three. The trade was taken knowingly: the error path is then written where it happens, in the order it happens. try also widened one error type into another by calling From invisibly, which no section of the spec ever said it did — so the conversion moved to the caller, at the return, where it is written down.

error[SC0155]: `try` was removed with the `Result` type
 --> failure.science:4:10
  |
4 | let n be try load("run.csv")
  |          ^^^ there is no `try` in Science
  |
  = note: a function that can fail returns its value and an error: write `let value, err be f()`, then `if err?:` and return
1 error

Removed from the language — kept only for comparison

let doc be try load("notes.txt")
def find(k: String) -> Option of Doc:
    Some(doc)
def load(p: String) -> Result of (Doc, Error):
    Ok(doc)

No t.0

A tuple is read by destructuring it or by a tuple pattern, and never by a numeric field. The . is a postfix operator whose right operand is a single name, forever — which is also the property that makes the dot rule of the reserved-word audit correct, and it is the same rule here working against a number.

error[SC0102]: expected an identifier, found a number literal
 --> tuple.science:2:13
  |
2 | print(point.0)
  |             ^ expected an identifier, found a number literal

error[SC0100]: expected `)`, found a number literal
error[SC0100]: expected end of line, found a number literal
3 errors

No negative indexing

This is the one absence whose diagnostic argues rather than instructs, and the argument is worth keeping: reading a[-1] as the last element is what makes a[i - 1] inside a loop return the last element at an i of zero, silently, instead of failing.

error[SC0152]: a negative index does not count from the end in Science
 --> index.science:2:10
  |
2 | print(xs[-1])
  |          ^^ an index runs from `0` up to the extent
  |
  = note: Python reads `a[-1]` as the last element, which is what makes `a[i - 1]` in a loop read the last element at an `i` of zero instead of failing
  = help: read the last element with: `.last()`
1 error

No lifetime syntax

There is nothing to write and nothing elided. <'a> is not a form the grammar knows, and the parser meets it as a < where it wanted a (, then as a character literal that never closes — four errors for a construct that does not exist, which is the honest failure of a grammar with no slot for it. The bet is stated in the type vocabulary: a borrow checker with nothing to annotate is either sound or it is a bug.