K is a small, self-contained, general-purpose language: variables,
closures, classes, error handling, and matrix math are all built-in
syntax, not a library you import. Source compiles to bytecode
(compiler.rs) and runs on a stack-based VM (vm.rs) — one binary, no
interpreter to install, no package manager.
class Dog {
fn init(name) { self.name = name; }
fn speak() { return f"{self.name} says Woof!"; }
}
let d = new Dog("Rex");
print(d.speak());
let weights = [[0.8, -0.1], [0.4, 0.9]];
let inputs = [[1.5, 0.2]];
print(relu(inputs @ weights)); // matrix multiply is a language operator, '@'- Getting started (CLI, REPL, GUI)
- Lexical basics — comments, semicolons, tokens
- Variables:
letandconst - Data types
- Operators
- String interpolation
- Lists and dicts
- Indexing (lists, strings, dicts)
- Control flow
- Functions
- Closures
- Classes and inheritance
- Error handling: try / catch / throw
import- Built-in functions (complete list)
- Built-in methods (list / dict / string)
- Matrix math
- Operator precedence table
- Full worked examples
- Gotchas and things that surprise people
- Quick syntax cheat-sheet
The compiled binary is called k. It supports:
| Command | What it does |
|---|---|
k script.k |
Lex → parse → compile → run the file, print output |
k (no args) |
Start the interactive REPL |
k repl |
Same as above, explicit |
k gui |
Launch the built-in graphical IDE (dark/light theme, F5 to run, Ctrl+S save, Ctrl+O open, Ctrl+N new) |
k -h / --help |
Print usage |
k -v / --version |
Print version |
REPL details: one VM instance stays alive for the whole session, so
variables/functions/classes defined on one line are visible on the
next. It supports arrow-key history, Ctrl+R search, and automatically
keeps reading additional lines while you have an unclosed {, (, or
[ (shown with a ... prompt). Special commands (must be typed at the
start of a fresh line):
:help— show REPL help:load <file.k>— load and execute a.kfile into the current session:vars— list all current global variables and their values:clear— clear the screen:exitor:q(or Ctrl+D) — quit
$ k repl
K Language REPL v0.1.0 — :help for commands, Ctrl+D or :exit to quit
k> let x = 40;
k> print(x + 2);
42
k> :vars
x = 40
k> :exit
Goodbye.
Every stage (lexer, parser, compiler) returns a Result — a bad script
never crashes the process, it prints Lex error: ..., Parse error: ..., or Compile error: ... and stops.
- Statements end in
;. The parser is lenient about a missing trailing;at the very end of a block (match_tokjust doesn't find one and moves on), but always terminate statements with;— it's the reliable, idiomatic style used throughout every example. - Comments:
- Line comments:
// like this, to end of line - Block comments:
/* like this, can span lines */— not nestable (an unterminated block comment is a lex error).
- Line comments:
- Identifiers: start with a letter or
_, followed by letters/digits/_. K is case-sensitive. - Numbers:
42is anInt(i64).3.14is aFloat(f64). A literal is only recognized as a float if there's at least one digit after the dot (so1.alone would not lex as a float digit sequence the way you might expect — always write1.0). - Strings: double-quoted only,
"like this". Supported escapes:\n,\t,\r,\",\\(any other escaped character is passed through literally). Single-quoted strings are not supported. - Reserved keywords (cannot be used as identifiers):
let const fn return if elif else while for in break continue true false null class new try catch throw and or not import
let name = "K";
const version = 1;letdeclares a mutable binding.constdeclares a binding that is intended to be constant.
Important — verified from the compiler source:
letandconstcurrently compile through the exact same code path and produce identical bytecode. There is no runtime enforcement that prevents reassigning aconst— it behaves exactly likelet. Treatconstas a documentation/intent signal for readers of your code, not as a language guarantee.
Optional type annotations are accepted after the name for documentation purposes only — the parser consumes and discards them, they do no type checking:
let count: int = 0; // ": int" is parsed and ignored
fn add(a: int, b: int) -> int { // param types and "-> int" return type: also ignored
return a + b;
}Reassignment uses =, +=, -=, *=, /= (there is no %= or
**=):
let total = 0;
total += 5; // total = total + 5
total -= 1;
total *= 2;
total /= 3;Variables declared at the top level of a script are globals;
variables declared with let/const inside a function body, loop, or
block are locals, resolved by the compiler to fixed stack slots
(fast — no hash-map lookup at runtime).
| Type | Example literal | Rust representation |
|---|---|---|
int |
42, -7 |
i64 |
float |
3.14, -0.5 |
f64 |
str |
"hello" |
Rc<String> |
bool |
true, false |
bool |
null |
null |
unit |
list |
[1, 2, 3] |
Rc<RefCell<Vec<Value>>> (reference type!) |
dict |
{"a": 1, "b": 2} |
Rc<RefCell<HashMap<String, Value>>> (reference type!) |
func |
a closure created by fn or a function expression |
— |
method |
a bound method (instance.method) |
— |
builtin |
a native function like print |
— |
class |
created with class |
— |
instance |
created with new ClassName(...) |
— |
type(value) returns the type name above as a string, e.g.
type(42) → "int", type([1,2]) → "list".
Lists and dicts are reference types. Assigning a list/dict to another variable, or passing it into a function, shares the same underlying storage — mutating it through one name is visible through the other:
let a = [1, 2, 3];
let b = a;
b.append(4);
print(a); // [1, 2, 3, 4] -- a and b are the same listUsed by if, while, and/or, and unary !/not:
| Value | Truthy? |
|---|---|
false, null |
falsy |
0 (int) |
falsy |
0.0 (float) |
falsy |
"" (empty string) |
falsy |
[] (empty list) |
falsy |
{} (empty dict) |
falsy |
| anything else (including negative numbers, non-empty containers, functions, instances) | truthy |
Int/Floatcompare across types numerically (1 == 1.0istrue).Str == Strcompares text.List == Listcompares length and element-wise equality (deep/structural, recursive).- Dicts, instances, functions, and classes are never equal to
anything via
==(not even to themselves as separate values) — the VM's equality function has no case for them and falls through tofalse. Don't rely on==to compare dicts or instances.
| Op | Meaning | Notes |
|---|---|---|
+ |
add / concatenate | see below |
- |
subtract | int - int stays int; otherwise float |
* |
multiply | int * int stays int; otherwise float |
/ |
divide | always returns a float, even 4 / 2 → 2.0. Dividing by zero throws a catchable runtime error "division by zero". |
% |
modulo | int % int (nonzero divisor) stays int; otherwise float |
** |
power | always returns a float; right-associative (2 ** 3 ** 2 = 2 ** (3 ** 2) = 512.0) |
unary - |
negate | always returns a float — -5 as a standalone unary expression evaluates to -5.0, not the int literal. (let x = -5; still gives you -5.0, not int -5. Only the bare literal 5 used positively is an Int.) |
@ |
matrix multiply | see §17 Matrix math |
+ overloads by type, checked in this order:
- If either operand is a
str, the other operand is converted to its display string and concatenated:"n=" + 5→"n=5",5 + "!"→"5!". - If both operands are
list, they're concatenated into a new list:[1,2] + [3]→[1, 2, 3]. - Otherwise, numeric addition (
int+intstaysint; mixed/float promotes tofloat).
== != < > <= >=
- Numbers compare numerically.
- Strings compare lexicographically (
"apple" < "banana"→true). - Mixing incompatible types (e.g. comparing a list with
<) is a runtime error.
and/or(keyword form) or&&/||(symbol form) — both are accepted and mean the same thing. These are short-circuiting: the right side is only evaluated if needed.not(keyword) or!(symbol) for logical negation. Both produce aboolregardless of the input's type (uses the truthiness table above).- There is no bitwise
&or|— a bare&or|not followed by its doubled partner is a lex error ("did you mean&&?").
= += -= *= /= — valid assignment targets are a plain
identifier (x = 1), an index (list[0] = 1, dict["k"] = 1), or a
field (obj.field = 1). Every assignment expression also evaluates to
the assigned value, so print(x = 5) prints 5.
Prefix a string literal with f to enable {expr} interpolation —
any full K expression can go inside the braces:
let name = "World";
let n = 3;
print(f"Hello, {name}! {n + 1} greetings.");
// Hello, World! 4 greetings.- Escapes (
\n,\t,\",\\) work the same as in normal strings. - Braces can nest (e.g. for a dict/list literal inside the
interpolation) — the lexer tracks brace depth so
{ {1:2}["1"] }-style expressions parse correctly. - Regular (non-
f) strings do not interpolate —"{name}"prints literally as{name}.
let empty = [];
let nums = [1, 2, 3];
let mixed = [1, "two", 3.0, true, null];
let nested = [[1, 2], [3, 4]]; // used for matrices, see §17Keys can be any expression, but only string keys actually work for
lookup — non-string keys are converted to their display string when
the dict is built (OpCode::BuildDict does to_display on any
non-Str key), so prefer literal string keys:
let d = {"a": 1, "b": 2, "c": 3};
print(d["a"]); // 1
d["d"] = 4; // add/replace a key
print(d.keys()); // ["a", "b", "c", "d"] (order not guaranteed — backed by a HashMap)Dict key order is not preserved — it's backed by a Rust
HashMap, so don't rely on insertion order when iteratingkeys()orfor k in dict.
Lists, strings, and dicts all support target[index]:
let l = [10, 20, 30];
print(l[0]); // 10
print(l[-1]); // 30 -- negative indices count from the end
l[1] = 99; // list index assignment
let s = "hello";
print(s[0]); // "h" -- indexing a string returns a 1-character string
print(s[-1]); // "o"
let d = {"x": 1};
print(d["x"]); // 1- Negative indexing is supported for lists and strings (
-1is the last element), but out-of-range indices (positive or negative) raise a catchable runtime error, e.g."list index out of range". - Strings are immutable — there is no
s[0] = "H"form (onlyListandDictsupport index assignment; assigning into a string index is a runtime error: "invalid index assignment target"). - Dict indexing requires a string key; a missing key raises
"key '<k>' not found".
if score >= 90 {
print("A");
} elif score >= 80 {
print("B");
} else if score >= 70 { // 'else if' also works, same as a chained 'elif'
print("C");
} else {
print("F");
}Note: conditions are not parenthesized and bodies always require
braces { } — there is no one-line, brace-less if.
let i = 0;
while i < 3 {
print("while loop:", i);
i += 1;
}Iterates over a list, a string (character by character), or a dict (its keys):
for n in range(1, 6) { print(n); } // 1 2 3 4 5
for ch in "abc" { print(ch); } // "a" "b" "c"
for key in {"x": 1, "y": 2} { print(key); } // iterates dict keys (order not guaranteed)Work exactly as you'd expect, including inside nested loops (each
break/continue applies to its own innermost enclosing loop).
for i in range(0, 10) {
if i == 3 { continue; }
if i == 6 { break; }
print(i);
}
// 0 1 2 4 5fn greet(who, greeting = "Hello") {
return f"{greeting}, {who}!";
}
print(greet("world")); // Hello, world!
print(greet("K", "Welcome")); // Welcome, K!- Declared with
fn name(params) { ... }. - Default parameter values are supported (
greeting = "Hello"above) — the default expression is evaluated fresh at call time if the argument is omitted. return expr;returns a value; a barereturn;(or falling off the end of the function body) returnsnull.- Functions support full recursion, including through nested/locally-declared functions.
- Calling with too few arguments does not error — missing
positional args (with no default) are simply bound to
null; extra arguments beyond the declared parameters are silently ignored (the VM only reads up toarityargs when building the call frame).
fn(params) { ... } used as an expression, without a name, creates a
closure value you can store or pass around:
let square = fn(x) { return x * x; };
print(square(5)); // 25
let add = fn(a, b) { return a + b; };
print(add(2, 3)); // 5K functions are real closures: a nested function that references a variable from an enclosing function keeps a live link to that variable (captured by reference through a shared cell), not a snapshot copy.
fn make_counter() {
let count = 0;
fn increment() {
count += 1;
return count;
}
return increment;
}
let counter1 = make_counter();
let counter2 = make_counter();
print(counter1()); // 1
print(counter1()); // 2
print(counter2()); // 1 -- independent state, not shared with counter1Under the hood: a plain local variable lives directly on the VM stack (cheap). Only variables actually captured by a nested closure get boxed into a shared, reference-counted cell — so closures cost extra only where you actually use them. This works through multiple levels of nesting (verified in the source comments up to 3-level nested closures).
class Shape {
fn init(name) { self.name = name; }
fn area() { return 0; }
fn describe() { return f"{self.name} has area {self.area()}"; }
}
class Circle(Shape) {
fn init(radius) {
self.name = "Circle";
self.radius = radius;
}
fn area() { return 3.14159 * self.radius ** 2; }
}
let c = new Circle(3);
print(c.describe()); // Circle has area 28.27431class Name { ... }declares a class; a class body may only contain methods (fn ...) — no top-level fields, no class-level constants.class Name(Parent) { ... }declares single inheritance fromParent. Method lookup walks up the parent chain if a method isn't found on the instance's own class (dynamic dispatch works: callingself.area()fromdescribe()inShapecorrectly callsCircle.area()on aCircleinstance).fn init(...)is the constructor, called automatically bynew ClassName(args). If a class has noinit,newjust creates an empty instance.selfis implicit inside every method — do not declare it as a parameter. It's automatically bound to slot 0 of every method call.- Fields are created dynamically the first time you assign
self.field = value— there's no upfront field declaration; assign whatever fields you need ininit(or later). new ClassName(args)instantiates: allocates the instance, runsinit(if defined) with those args, and returns the instance.
Errors in K are ordinary catchable values — a runtime error (like
division by zero, a missing key, an out-of-range index) never crashes
the process; it unwinds to the nearest enclosing try/catch,
propagating cleanly across nested function calls.
fn safe_divide(a, b) {
try {
return a / b;
} catch e {
print("error:", e);
return null;
}
}
print(safe_divide(10, 2)); // 5.0
print(safe_divide(10, 0)); // error: division by zero -> then prints null
print("program kept running");try { ... } catch <name> { ... }— the catch variable name is optional; if omitted it defaults toerr(catch { ... }is the same ascatch err { ... }).throw expr;raisesexpras the error value — it can be any K value, not just a string (throw "bad input";,throw 42;,throw {"code": 404};are all valid).- An error thrown with no enclosing
try/catchpropagates all the way out and the program printsUncaught error: <value>and stops. try/catchcorrectly unwinds across nested function calls — athrowdeep inside several levels of function calls is caught by the nearest activetryup the call chain, not just the immediate caller.
import "lib.k";import "<path>" reads the named file at compile time, tokenizes
and parses it, and splices its statements directly into the current
compile unit (like a textual include) — it is not a module system with
namespaces; everything imported lands directly in the current scope.
The path is relative to wherever the k process is run from.
These are always available as globals (no import needed) — this is the exact and complete list implemented by the VM, nothing more:
| Function | Signature | Behavior |
|---|---|---|
print(...) |
print(a, b, c, ...) |
Prints all args, space-separated, followed by a newline. Returns null. |
len(x) |
list / dict / str | Number of elements / keys / characters. |
str(x) |
any | Converts to its display string. |
int(x) |
int / float / bool / str | Converts to int. Truncates floats. Parses numeric strings (trims whitespace); a non-numeric string errors. |
float(x) |
any number-like | Converts to float. |
bool(x) |
any | Converts using the truthiness table (§4). |
type(x) |
any | Returns the type name as a string ("int", "list", etc). |
range(end) / range(start, end) / range(start, end, step) |
ints | Returns a list of ints. step cannot be 0. Works with negative steps for descending ranges. |
abs(x) |
number | Absolute value, always returned as a float. |
min(...) |
min(a, b, ...) or min(list) |
Minimum, as a float. |
max(...) |
max(a, b, ...) or max(list) |
Maximum, as a float. |
sum(list) |
list of numbers | Sum, as a float. |
sorted(list) |
list of numbers | Returns a new sorted list (ascending); does not mutate the input. |
round(x) |
number | Rounds to nearest integer, returned as int. |
input() |
— | Present but currently always returns an empty string "" (not wired to real stdin reading). |
relu(x) |
number or (nested) list | Elementwise max(x, 0). |
sigmoid(x) |
number or (nested) list | Elementwise 1 / (1 + e^-x). |
tanh(x) |
number or (nested) list | Elementwise hyperbolic tangent. |
softmax(list) |
flat list of numbers | Numerically-stable softmax (subtracts the max before exponentiating). |
transpose(matrix) |
list of lists | Matrix transpose. |
flatten(x) |
(nested) list | Recursively flattens any depth of nested lists into one flat list. |
Everything else (e.g. print, min, max) is exactly what's shown
above — there's no math.sqrt, no file I/O, no random, no map,
filter, or reduce builtin. If you need behavior beyond this list,
you write it yourself in K.
Methods are called with dot syntax: value.method(args).
| Method | Effect |
|---|---|
.append(x) / .push(x) |
Adds x to the end (mutates in place). Returns null. |
.pop() |
Removes and returns the last element (mutates in place). Returns null if the list is empty. |
.sort() |
Sorts numerically ascending, in place. Returns null. |
.reverse() |
Reverses in place. Returns null. |
.contains(x) |
Returns bool — whether x is present (uses the same deep equality as ==). |
| Method | Effect |
|---|---|
.keys() |
Returns a list of keys (order not guaranteed). |
.values() |
Returns a list of values (order not guaranteed). |
.get(key) / .get(key, default) |
Returns the value for key, or default (or null if no default given) when missing. Does not error on a missing key (unlike dict[key]). |
.remove(key) |
Removes key and returns its value, or null if absent. |
Strings are immutable — every string method returns a new string (or other value) rather than mutating.
| Method | Effect |
|---|---|
.upper() |
Uppercase copy. |
.lower() |
Lowercase copy. |
.trim() |
Copy with leading/trailing whitespace removed. |
.split(sep) |
Splits on sep (defaults to " " if omitted), returns a list of strings. |
.replace(old, new) |
Replaces all occurrences of old with new. |
.contains(sub) |
bool — substring test. |
.startsWith(sub) |
bool. |
.endsWith(sub) |
bool. |
let s = " Hello World ";
print(s.trim().lower()); // "hello world"
print(s.trim().split(" ")); // ["Hello", "World"]
print("banana".replace("a", "o")); // "bonono"
print("K lang".startsWith("K")); // trueMatrices are just lists of lists of numbers — there's no separate
matrix type. @ is a real binary operator (parses at the same
precedence level as *, /, %) that performs matrix multiplication:
let inputs = [[1.5, 0.2]];
let weights = [[0.8, -0.1], [0.4, 0.9]];
let hidden = inputs @ weights;
print("hidden:", hidden); // [[1.28, 0.03]]
print("activated:", relu(hidden));
print("transposed weights:", transpose(weights));
print("softmax:", softmax([2.0, 1.0, 0.1]));a @ brequires both operands to be non-empty "matrices" (lists of lists of numbers) with compatible inner dimensions (a's row length must equalb's row count) — otherwise it's a runtime error ("matrix dimension mismatch" or "requires two matrices").transpose(m)swaps rows/columns.relu,sigmoid,tanhapply elementwise and work on scalars, flat lists, or nested (matrix) lists uniformly — they recurse into nested lists automatically.softmax(list)expects a flat list of numbers, not a matrix.flatten(m)collapses any depth of nested lists into one flat list.- All matrix/elementwise results come back as
floatvalues.
From loosest to tightest binding (matches the parser's recursive-descent chain exactly):
| Level | Operators | Associativity |
|---|---|---|
| 1 (loosest) | = += -= *= /= (assignment) |
right |
| 2 | or / || |
left |
| 3 | and / && |
left |
| 4 | == != |
left |
| 5 | < > <= >= |
left |
| 6 | + - |
left |
| 7 | * / % @ |
left |
| 8 | ** (power) |
right |
| 9 | unary -, ! / not |
— |
| 10 (tightest) | call (), index [], field . |
left |
print(2 + 3 * 4 ** 2); // 2 + 3*16 = 50
print(-2 ** 2); // unary binds looser than **: -(2**2) = -4.0These are the actual example scripts shipped in examples/, annotated.
// K basics: variables, control flow, functions, string interpolation.
let name = "K";
const version = 1;
print(f"Hello from {name} v{version}!");
fn greet(who, greeting = "Hello") {
return f"{greeting}, {who}!";
}
print(greet("world"));
print(greet("K", "Welcome"));
let total = 0;
for n in range(1, 6) {
total += n;
}
print(f"1..5 sums to {total}");
let i = 0;
while i < 3 {
print("while loop:", i);
i += 1;
}Output:
Hello from K v1!
Hello, world!
Welcome, K!
1..5 sums to 15
while loop: 0
while loop: 1
while loop: 2
// Classes with inheritance. 'self' is implicit inside a method --
// do not declare it as a parameter.
class Shape {
fn init(name) { self.name = name; }
fn area() { return 0; }
fn describe() { return f"{self.name} has area {self.area()}"; }
}
class Circle(Shape) {
fn init(radius) {
self.name = "Circle";
self.radius = radius;
}
fn area() { return 3.14159 * self.radius ** 2; }
}
let c = new Circle(3);
print(c.describe());Output: Circle has area 28.27431
// Errors are catchable values, never a crash.
fn safe_divide(a, b) {
try {
return a / b;
} catch e {
print("error:", e);
return null;
}
}
print(safe_divide(10, 2));
print(safe_divide(10, 0));
print("program kept running");Output:
5.0
error: division by zero
null
program kept running
// Matrix math is built-in syntax ('@'), not a library import.
let inputs = [[1.5, 0.2]];
let weights = [[0.8, -0.1], [0.4, 0.9]];
let hidden = inputs @ weights;
print("hidden:", hidden);
print("activated:", relu(hidden));
print("transposed weights:", transpose(weights));
print("softmax:", softmax([2.0, 1.0, 0.1]));Output:
hidden: [[1.28, 0.03]]
activated: [[1.28, 0.03]]
transposed weights: [[0.8, 0.4], [-0.1, 0.9]]
softmax: [0.710949502625004, 0.2595601109490743, 0.02949038642592176]
class Stack {
fn init() { self.items = []; }
fn push(x) { self.items.append(x); }
fn pop() {
if len(self.items) == 0 {
throw "pop from empty stack";
}
return self.items.pop();
}
fn size() { return len(self.items); }
}
let s = new Stack();
s.push(1);
s.push(2);
s.push(3);
print(f"stack size: {s.size()}");
print(s.pop()); // 3
try {
s.pop(); s.pop();
s.pop(); // this one throws: stack is now empty
} catch e {
print(f"caught: {e}");
}
fn make_adder(n) {
fn adder(x) { return x + n; }
return adder;
}
let add5 = make_adder(5);
print(add5(10)); // 15constdoesn't actually stop reassignment. It compiles identically tolet— it's a naming convention only.- Type annotations (
: int,-> int) are parsed and thrown away. K has no static type checking; annotate for readability only. /,**, and unary-always produce afloat, even on two ints.4 / 2is2.0, not2.-5as an expression is-5.0. Only+,-(binary),*, and%preserveintwhen both operands areint.- Lists and dicts are shared references, not copies, when assigned or passed to functions — mutating one name mutates every other name pointing at the same list/dict.
==never returnstruefor two dicts or two instances, even if they hold identical data — the equality function has no case for those types.- Dict/keys iteration order is not guaranteed (backed by a hash map, not an ordered map).
- Extra function arguments are silently ignored; missing ones become
null— there's no arity-mismatch error at call time. selfmust never be declared as a parameter in a method — it's implicitly bound to slot 0 automatically.- Class bodies may only contain
fnmethods — no fields, no nested classes, no class-level constants. - String indices/negative indices work, but strings are
immutable — you cannot assign into
s[i]. importis a compile-time textual splice, not a namespaced module system — everything imported lands directly in your current scope, so name collisions are your responsibility.input()is a stub — it always returns"", it does not actually read from stdin in the current implementation.- No bitwise
&/|— only the doubled&&/||(logical) forms exist; a lone&or|is a lex error. - No
elif-less one-linerif— braces are always required, and there's no ternary? :operator.
// variables
let x = 1;
const PI = 3.14159;
// types
let i = 1; let f = 1.5; let s = "hi"; let b = true; let n = null;
let l = [1, 2, 3];
let d = {"key": "value"};
// string interpolation
print(f"x is {x}, doubled is {x * 2}");
// control flow
if x > 0 { }
elif x == 0 { }
else { }
while x < 10 { x += 1; }
for item in [1, 2, 3] { print(item); }
for i in range(0, 10, 2) { print(i); } // start, end, step
// functions
fn add(a, b = 0) { return a + b; }
let square = fn(x) { return x * x; };
// closures
fn counter() {
let n = 0;
fn inc() { n += 1; return n; }
return inc;
}
// classes
class Animal {
fn init(name) { self.name = name; }
fn speak() { return "..."; }
}
class Dog(Animal) {
fn speak() { return f"{self.name} says Woof"; }
}
let d = new Dog("Rex");
// errors
try {
throw "oops";
} catch e {
print(e);
}
// matrices
let m = [[1, 2], [3, 4]] @ [[5, 6], [7, 8]];
// import (compile-time textual include)
import "helpers.k";