The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

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:

This code does not compile
@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:

This code does not compile
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:

This code does not compile
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:

This code does not compile
@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 two int values gives an int, truncated toward zero (7 / 2 is 3). // is floor division (-7 // 2 is -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:

This code does not compile
@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:

This code does not do what it should
@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:

This code throws at run time
@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 an Option, int32.wrappingAdd, int32.saturatingAdd and the rest of the family) are not implemented in Loup 1.25.1; the compiler reports type '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:

This code does not do what it should
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 as 99_999_999_999_999_999_999n) and decimal (literals such as 0.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.2d is rejected with type 'decimal' does not implement 'Add', println refuses a bigint, and the bigint(…)/decimal(…) constructors fail at run time with internal 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:

This code does not compile
@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:

This code does not compile
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:

This code does not do what it should
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:

This code does not compile
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 typeof operator (typeof x returning a Type value) is not implemented in Loup 1.25.1 (expected expression), and x instanceof T crashes the 1.25.1 parser. Use x is T, shown in Listing 3-13; the Language Reference defines is as a synonym of instanceof (§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:

This code does not compile
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

  • let bindings cannot be reassigned, var bindings can, and const values 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, int overflow wraps silently while sized types like int32 throw NumericOverflowError; bigint, decimal and the checkedAdd family 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.
  • bytes holds raw data; insert it into text through a filter such as base64.
  • There is no null: optional values are Option[T].
  • Union types are narrowed with match or is; type aliases rename a type without creating a new one.