Chapter 3: Variables and Data Types
What you'll learn. How let, var and const differ, the primitive types, how integers and floats behave (including what happens on overflow today), characters and strings, bytes, Option instead of null, union types, and type aliases.
Why it matters. Most production bugs are not exotic: a number of the wrong kind arrives in the wrong place, or a missing value is treated as present. Loup refuses to convert between numeric kinds silently and puts "might be missing" into the type, so many of those bugs become compile errors.
Everything in this chapter is the same in web.compiled and web.scripted. The listings use web.compiled, so each program's statements live in main(), which loup run calls for you (Chapter 2 explains why).
let and var
let declares a binding you cannot reassign; var declares one you can:
@mode(web.compiled)
func main(): void {
let price = 19.99; # immutable
var quantity = 1; # mutable
quantity = quantity + 2;
println(price);
println(quantity);
}
Listing 3-1: let for a value that stays put, var for one that changes
19.99
3
Reach for let first and switch to var only when the value really has to change. The point is not speed; it is that a reader can see at a glance which names move.
If you reassign a let, the compiler stops you:
@mode(web.compiled)
func main(): void {
let total = 10;
total = 11;
println(total);
}
main.lpweb:5:5: error[E0001]: cannot assign to immutable binding 'total'
The message names the binding. The fix is to declare it with var if it is meant to change, or to introduce a new name (let adjusted = total + 1;) if it is not.
Redeclaring a name in the same scope is also an error, so you cannot "shadow" your way around a let the way you can in Rust:
func main(): void {
let total = 10;
let total = 11;
println(total);
}
main.lpweb:5:9: error[E0001]: duplicate declaration of `total` in the same scope (E_REDECLARED)
Pick a second name, such as adjusted.
Type inference and annotations
Inside a function, Loup infers a binding's type from its initializer. You can always write the type yourself after a colon:
func main(): void {
let count = 10; # int
let ratio = 0.75; # float
let label = "draft"; # string
let ready: bool = false; # annotated
let tags: list[string] = []; # annotated: an empty list has no element to infer from
println(count);
println(ratio);
println(label);
println(ready);
println(tags);
}
Listing 3-2: Inferred and annotated bindings
10
0.75
draft
false
[]
A binding needs either a type or an initializer. With neither, there is nothing to infer from:
func main(): void {
var retries;
retries = 3;
println(retries);
}
main.lpweb:4:5: error[E0001]: binding 'retries' has no type annotation and no initializer; cannot infer type
Write var retries: int = 0; or initialize it where you declare it. An annotation is also checked against the initializer: var count: int = "3"; is rejected with cannot assign 'string' to binding 'count' of type 'int'.
Function signatures are different: parameter and return types are part of the function's contract and are written out (Chapter 4).
Constants: const
const is for values the compiler can compute while it compiles: literals, arithmetic on other constants, and calls to const func functions (Language Reference §1-7.1). Constants usually live at module level:
@mode(web.compiled)
const TAX_RATE: float = 0.0725;
const KB: int = 1024;
const MB: int = KB * 1024;
const func square(n: int): int {
return n * n;
}
const TILE_AREA: int = square(12);
func main(): void {
println(TAX_RATE);
println(MB);
println(TILE_AREA);
}
Listing 3-3: Compile-time constants
0.0725
1048576
144
A const whose initializer needs a running program is rejected:
@mode(web.compiled)
func siteName(): string {
return "Example Store";
}
const SITE: string = siteName();
func main(): void {
println(SITE);
}
main.lpweb:7:22: error[E_CONST_NOT_EVALUABLE]: the initialiser of `const SITE` is not a const-expression — use `let` for a runtime value (E_CONST_NOT_EVALUABLE)
siteName is an ordinary function, so the compiler cannot run it. Either mark it const func (it only returns a literal, so that works), or compute the value at run time with let inside main.
Primitive types
These are the primitive types (Language Reference §1-4.1):
| Type | What it holds |
|---|---|
int |
64-bit signed integer (alias for int64) |
int8, int16, int32, int64 |
Signed integers of a fixed width |
uint8, uint16, uint32, uint64 |
Unsigned integers of a fixed width |
float |
64-bit IEEE 754 float (alias for float64) |
float32 |
32-bit IEEE 754 float |
bool |
true or false |
char |
One Unicode scalar value, written c"A" |
string |
Immutable UTF-8 text |
bytes |
An arbitrary byte sequence, written b"..." |
The literal forms:
func main(): void {
println(1_000_000); # underscores for readability
println(0xff); # hex
println(0b1010); # binary
println(0o755); # octal
println(3.14);
println(2.5e3); # exponent
println(true);
println(c"λ"); # a char
println("\u{1F600}"); # escape by code point
}
Listing 3-4: Literal syntax
1000000
255
10
493
3.14
2500
true
λ
😀
Notice that println(2.5e3) printed 2500: printing renders a whole-number float without its .0. repr(2500.0) shows 2500.0 when you need to see the type.
Arithmetic
+, -, *, /, //, % and ** work on both integers and floats. Two details trip up newcomers:
/on twointvalues gives anint, truncated toward zero (7 / 2is3).//is floor division (-7 // 2is-4).- Loup never mixes integers and floats on its own. You convert one side with
as.
Here is the second one in a shopping-cart total:
@mode(web.compiled)
func main(): void {
let quantity = 3;
let unitPrice = 2.5;
let total = quantity * unitPrice;
println(total);
}
main.lpweb:6:17: error[E_OPERATOR_MISSING]: E_NUMERIC_TYPE_MISMATCH: mixed 'int' and 'float' operands for operator '*' — convert one operand explicitly (`as float` or `as int`)
The message says what to do. In TypeScript or Python this would quietly produce a float; Loup makes you choose:
@mode(web.compiled)
func main(): void {
let quantity = 3;
let unitPrice = 2.5;
let total = (quantity as float) * unitPrice;
println(total);
println(7 / 2);
println(7 // 2);
println(-7 / 2);
println(-7 // 2);
println(2 ** 10);
}
Listing 3-5: Converting explicitly, and the two divisions
7.5
3
3
-3
-4
1024
Integer overflow
The Language Reference says integer arithmetic in the web modes is checked: a result that does not fit its type throws NumericOverflowError (§1-4.1.1). Loup 1.25.1 does this for the sized types such as int32 and uint8, but not for int: int arithmetic silently wraps around. This program computes factorials in int:
@mode(web.compiled)
func factorial(n: int): int {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
func main(): void {
println(factorial(20));
println(factorial(21));
}
2432902008176640000
-4249290049419214848
21! is larger than the biggest int (about 9.2 × 10¹⁸), and instead of an error you get a negative number. Until the toolchain implements the specified check, guard the range yourself when a value can grow without bound (here: refuse n > 20).
The sized types already behave as specified. The same mistake in int32 stops the program:
@mode(web.compiled)
func main(): void {
let pixels: int32 = 2_000_000_000;
let doubled = pixels + pixels;
println(doubled);
}
Uncaught NumericOverflowError: arithmetic overflow: 4000000000 does not fit int32
at main (main.lpweb:5:19)
⚠ Planned. The explicit overflow functions in Language Reference §1-4.1.1 (
int32.checkedAdd(a, b)returning anOption,int32.wrappingAdd,int32.saturatingAddand the rest of the family) are not implemented in Loup 1.25.1; the compiler reportstype 'int32' has no method 'checkedAdd'.
Converting between numeric types
x as T converts between numeric types (Language Reference §1-4.1.2). A float converted to an integer is truncated toward zero; a value that does not fit the target type throws NumericOverflowError in the web modes:
func main(): void {
let measured = 3.99;
println(measured as int);
let small: int8 = 100 as int8;
println(small);
let wide: int = small; # widening within a family is implicit
println(wide + 1);
let ratio: float32 = 0.5 as float32;
println(ratio);
}
Listing 3-6: Numeric conversions with as
3
100
101
0.5
To turn text into a number, strings have to_int() and to_float(). Be careful: in Loup 1.25.1 to_int() returns 0 for text that is not a number, so check the input yourself when 0 is a legal value:
func main(): void {
println("42".to_int());
println("3.5".to_float());
println("12a".to_int()); # not a number, but no error
}
42
3.5
0
bigint and decimal
⚠ Planned. The Language Reference specifies two arbitrary-precision types:
bigint(literals such as99_999_999_999_999_999_999n) anddecimal(literals such as0.1d, exact base-10 arithmetic for money) (§1-4.1.3, §1-4.1.4). Loup 1.25.1 parses their literals but cannot compute with them:0.1d + 0.2dis rejected withtype 'decimal' does not implement 'Add',printlnrefuses abigint, and thebigint(…)/decimal(…)constructors fail at run time withinternal interpreter limitation: no builtin named 'bigint'. Until they land, keep money in integer cents.
Binary floats cannot represent most decimal fractions exactly, which is why money should not live in float:
func main(): void {
println(0.1 + 0.2);
println(0.1 + 0.2 == 0.3);
let cents = 10 + 20; # keep money in integer cents
println(cents == 30);
}
0.30000000000000004
false
true
Characters and strings
A string is immutable UTF-8 text. Its methods count Unicode scalar values, not bytes:
@mode(web.compiled)
func main(): void {
let city = "Zürich";
println(city.length()); # 6 characters
println(city.byte_length()); # 7 bytes: ü takes two
println(city[1]); # indexing gives a one-character string
println(city.to_uppercase());
println(city.contains("rich"));
println("a,b,c".split(","));
for (letter in "héllo") {
print(letter);
print("|");
}
println("");
}
Listing 3-7: Strings are sequences of characters
6
7
ü
ZÜRICH
true
["a", "b", "c"]
h|é|l|l|o|
Indexing and iteration give you one-character strings, not char values (Language Reference §1-4.1.5). A char is a separate type for a single scalar value, written c"A", and is used where the code point itself matters:
func main(): void {
let initial = c"a";
println(initial.toInt()); # the code point
println(initial.toUpper());
println(initial.isAlpha());
println(initial.toString() + "!");
println("Ada".scalars()); # the chars of a string
}
Listing 3-8: The char type
97
A
true
a!
[A, d, a]
Because iteration yields strings, comparing the loop variable with a char literal is a type error. This vowel counter makes that mistake:
@mode(web.compiled)
func countVowels(text: string): int {
var count = 0;
for (letter in text) {
if (letter == c"a" or letter == c"e" or letter == c"o") {
count = count + 1;
}
}
return count;
}
func main(): void {
println(countVowels("hello world"));
}
main.lpweb:6:13: error[E_EQUALITY_TYPE_MISMATCH]: cannot compare 'string' and 'char' for equality: `==` and `!=` compare values of one type or of related types (E_EQUALITY_TYPE_MISMATCH)
main.lpweb:6:31: error[E_EQUALITY_TYPE_MISMATCH]: cannot compare 'string' and 'char' for equality: `==` and `!=` compare values of one type or of related types (E_EQUALITY_TYPE_MISMATCH)
main.lpweb:6:49: error[E_EQUALITY_TYPE_MISMATCH]: cannot compare 'string' and 'char' for equality: `==` and `!=` compare values of one type or of related types (E_EQUALITY_TYPE_MISMATCH)
letter is a string, so compare it with strings:
@mode(web.compiled)
func countVowels(text: string): int {
var count = 0;
for (letter in text) {
if (letter == "a" or letter == "e" or letter == "o") {
count = count + 1;
}
}
return count;
}
func main(): void {
println(countVowels("hello world"));
}
Listing 3-9: Counting vowels by comparing one-character strings
3
as does not convert to or from char; the compiler points you to the conversion functions:
func main(): void {
let letter = 65 as char;
println(letter);
}
main.lpweb:4:18: error[E0001]: E_CHAR_CAST: `as` does not convert to or from `char` — use `char.of(n)`, `c.toInt()`, `char.fromString(s)` or `c.toString()` (§1-4.1.5)
Interpolation
A plain string literal is taken literally, braces and all. To insert values, prefix the string with i and wrap each expression in {$ … $} (Language Reference §1-15). Forgetting the prefix is not an error, which makes it easy to miss:
func main(): void {
let name = "Ada";
let items = 3;
println("Hello, {name}! You have {items} items in your cart.");
}
Hello, {name}! You have {items} items in your cart.
The interpolated form, with a filter applied to one value:
func main(): void {
let name = "Ada";
let items = 3;
println(i"Hello, {$ name $}! You have {$ items $} items in your cart.");
println(i"Shipping to {$ name | upper $}");
}
Listing 3-10: Interpolated strings with i"…"
Hello, Ada! You have 3 items in your cart.
Shipping to ADA
Template fences, the multi-line big sibling of interpolated strings, are the subject of Chapter 14.
Multi-line strings
Triple quotes make a multi-line string. The whitespace in front of the opening """ line is the left margin: every content line must start with it, it is removed from each line, and the closing """ sits at that margin (Language Reference §1-14):
func main(): void {
let note = """
Dear customer,
thanks for your order.
""";
println(note);
}
Dear customer,
thanks for your order.
bytes
bytes holds raw bytes that need not be valid UTF-8, such as a file upload or a hash. A b"…" literal makes one; indexing gives a uint8:
func main(): void {
let data = b"Hi!";
println(data.length());
println(data[0]);
println(i"base64: {$ data | base64 $}");
println(i"hex: {$ data | hex $}");
}
Listing 3-11: bytes and the base64/hex filters
3
72
base64: SGkh
hex: 486921
The Language Reference makes inserting bytes into an interpolation without a filter a compile error (E_TEMPLATE_BYTES, §1-16.4), so binary data cannot leak into a page by accident. Loup 1.25.1 does not enforce that yet: i"{$ data $}" renders the placeholder Bytes(3 bytes). Always choose a filter.
No null: Option[T]
Loup has no null. A value that may be missing has the type Option[T], which is either Some(value) or None, and a plain int can never be None:
func main(): void {
let discount: int = None;
println(discount);
}
main.lpweb:4:5: error[E0001]: cannot assign 'None' to binding 'discount' of type 'int'
Say that the value is optional, and supply a fallback where you use it:
func main(): void {
let discount: Option[int] = None;
let coupon: Option[string] = Some("SPRING");
println(discount.unwrap_or(0));
println(coupon.unwrap_or("none"));
println(coupon.is_some());
}
Listing 3-12: Optional values
0
SPRING
true
The method names are snake_case (unwrap_or, is_some, is_none). Chapter 8 covers Option and its partner Result in depth.
Union types
A union type A | B holds a value of either type, without declaring an enum (Language Reference §1-4.5). To use the value you narrow it, either with a match on type patterns or with is:
@mode(web.compiled)
func describe(value: string | int | bool): string {
return match value {
string(s) => i"text: {$ s $}",
int(n) => i"number: {$ n $}",
bool(b) => if (b) { "yes" } else { "no" },
};
}
func nextLabel(value: string | int): string {
if (value is string) {
return value.to_uppercase(); # narrowed to string
} else {
return str(value + 1); # narrowed to int
}
}
func main(): void {
println(describe("hi"));
println(describe(42));
println(describe(true));
println(nextLabel("draft"));
println(nextLabel(41));
}
Listing 3-13: Narrowing a union with match and is
text: hi
number: 42
yes
DRAFT
42
Narrow before you print: Loup 1.25.1 does not accept an un-narrowed union as a println argument (expected 'Any', found 'int | string').
Use a union for an ad-hoc "one of these types"; use an enum (Chapter 8) when the alternatives have names and meaning of their own.
Type aliases
type Name = … gives a type a shorter or more meaningful name. An alias is the same type as what it names, and aliases can take type parameters:
@mode(web.compiled)
type UserId = int;
type Directory = dict[UserId, string];
type Parsed[T] = Result[T, string];
func lookup(users: Directory, id: UserId): string {
return users.get(id).unwrap_or("unknown");
}
func parsePort(raw: string): Parsed[int] {
let port = raw.to_int();
if (port <= 0 or port > 65535) {
return Err(i"bad port: {$ raw $}");
}
return Ok(port);
}
func main(): void {
let users: Directory = {1: "ann", 2: "bob"};
println(lookup(users, 2));
println(lookup(users, 9));
let id: UserId = 7;
let plain: int = id + 1; # an alias is interchangeable with its type
println(plain);
println(repr(parsePort("8080")));
println(repr(parsePort("http")));
}
Listing 3-14: Type aliases, including a generic one
bob
unknown
8
Ok(8080)
Err("bad port: http")
Because UserId is int, nothing stops you from passing an order number where a user ID was expected. When you want the compiler to keep them apart, wrap the value in a one-field struct (Chapter 7).
⚠ Planned. The
typeofoperator (typeof xreturning aTypevalue) is not implemented in Loup 1.25.1 (expected expression), andx instanceof Tcrashes the 1.25.1 parser. Usex is T, shown in Listing 3-13; the Language Reference definesisas a synonym ofinstanceof(§1-4.7).
Names you cannot use
Keywords are reserved and cannot name a binding (Language Reference §1-3.4). Several are words you might well want for a variable:
func main(): void {
let type = "admin";
println(type);
}
main.lpweb:4:9: error[E0001]: `type` is a reserved word (§1-3.4) and cannot name a binding
note: rename the binding — e.g. `type_value`
main.lpweb:5:13: error[E0001]: expected expression
The first message is the real one; the second follows from it. Rename the binding (let role = "admin";). class, match, from, this and template are other common collisions.
Summary
letbindings cannot be reassigned,varbindings can, andconstvalues are computed at compile time.- Types are inferred from initializers; annotate when there is nothing to infer from.
- Integers and floats never mix implicitly: convert with
as./on integers truncates;//floors. - In Loup 1.25.1,
intoverflow wraps silently while sized types likeint32throwNumericOverflowError;bigint,decimaland thecheckedAddfamily are planned. - Strings are UTF-8; indexing and iteration give one-character strings, and
char(c"A") is a separate type. - Use
i"…{$ x $}…"to interpolate; a plain string keeps its braces. bytesholds raw data; insert it into text through a filter such asbase64.- There is no null: optional values are
Option[T]. - Union types are narrowed with
matchoris; type aliases rename a type without creating a new one.